@app-vitals/agent-runtime 0.0.1 → 0.1.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/Dockerfile +265 -0
- package/agent-account-status-ref.ts +53 -0
- package/agent-account-status-ref.unit.test.ts +24 -0
- package/agent-extension.ts +144 -0
- package/agent-extension.unit.test.ts +139 -0
- package/agent-repos-ref.ts +63 -0
- package/agent-repos-ref.unit.test.ts +84 -0
- package/agent-slack-membership-ref.ts +94 -0
- package/agent-slack-membership-ref.unit.test.ts +201 -0
- package/agent-trial-expiry-ref.ts +63 -0
- package/agent-trial-expiry-ref.unit.test.ts +84 -0
- package/anthropic-federation-guard.ts +72 -0
- package/anthropic-federation-guard.unit.test.ts +127 -0
- package/base-image-contract.smoke.test.ts +149 -0
- package/boot.ts +845 -0
- package/browser.integration.test.ts +52 -0
- package/browser.ts +34 -0
- package/chat-poller.integration.test.ts +109 -0
- package/chat-poller.ts +401 -0
- package/chat-poller.unit.test.ts +1581 -0
- package/chat-token-reporter.integration.test.ts +282 -0
- package/chat-token-reporter.ts +136 -0
- package/claude-version.ts +55 -0
- package/claude-version.unit.test.ts +73 -0
- package/claude.ts +1054 -0
- package/claude.unit.test.ts +3042 -0
- package/cleanup-after-days-ref.ts +83 -0
- package/cleanup-after-days-ref.unit.test.ts +89 -0
- package/cleanup-merged-worktrees-ref.ts +75 -0
- package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
- package/cli-args.ts +36 -0
- package/cli-args.unit.test.ts +94 -0
- package/clock.ts +47 -0
- package/config.ts +99 -0
- package/config.unit.test.ts +356 -0
- package/context-stamp.ts +189 -0
- package/context-stamp.unit.test.ts +159 -0
- package/cron-failure-reporter.ts +107 -0
- package/cron-failure-reporter.unit.test.ts +244 -0
- package/cron-handler.integration.test.ts +614 -0
- package/cron-handler.smoke.test.ts +1371 -0
- package/cron-handler.ts +750 -0
- package/cron-handler.unit.test.ts +1177 -0
- package/cron-run-reporter.integration.test.ts +806 -0
- package/cron-run-reporter.ts +398 -0
- package/crypto.ts +73 -0
- package/cutover-validate.integration.test.ts +245 -0
- package/cutover-validate.ts +84 -0
- package/entrypoint-main.ts +79 -0
- package/entrypoint.integration.test.ts +315 -0
- package/entrypoint.ts +154 -0
- package/errors.ts +61 -0
- package/fixtures/chat-service/attachment.json +15 -0
- package/fixtures/chat-service/messages.json +86 -0
- package/fixtures/chat-service/threads.json +50 -0
- package/fixtures/github-installations/installations.json +1231 -0
- package/fixtures/slack-provision-cassette.json +6 -0
- package/fixtures/specialties/fixture/manifest.yaml +35 -0
- package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
- package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
- package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
- package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
- package/fixtures/stream-json/clean-multi-turn.ts +209 -0
- package/fixtures/stream-json/progress-phases.ts +203 -0
- package/fixtures/stream-json/skill-attribution.ts +251 -0
- package/fixtures/stream-json/truncated-no-result.ts +84 -0
- package/format.ts +283 -0
- package/format.unit.test.ts +558 -0
- package/gh-helpers.ts +139 -0
- package/gh-helpers.unit.test.ts +253 -0
- package/gh-token-files.integration.test.ts +143 -0
- package/gh-token-files.ts +92 -0
- package/gh-token-files.unit.test.ts +63 -0
- package/github-app-auth.integration.test.ts +419 -0
- package/github-app-auth.ts +193 -0
- package/github-auth-deps.ts +128 -0
- package/github-auth-startup.ts +231 -0
- package/github-auth-startup.unit.test.ts +325 -0
- package/github-installations-manager.integration.test.ts +323 -0
- package/github-installations-reporter.integration.test.ts +124 -0
- package/github-installations-reporter.ts +150 -0
- package/github-installations-reporter.unit.test.ts +166 -0
- package/github-installations.integration.test.ts +88 -0
- package/github-installations.ts +462 -0
- package/github-installations.unit.test.ts +98 -0
- package/health.smoke.test.ts +244 -0
- package/health.ts +193 -0
- package/health.unit.test.ts +40 -0
- package/http-chat-service-client.integration.test.ts +345 -0
- package/http-chat-service-client.ts +287 -0
- package/http-chat-service-client.unit.test.ts +500 -0
- package/log-prefix.ts +12 -0
- package/log-prefix.unit.test.ts +24 -0
- package/markers.ts +178 -0
- package/markers.unit.test.ts +374 -0
- package/package.json +38 -4
- package/piper-voice.ts +116 -0
- package/piper-voice.unit.test.ts +124 -0
- package/pricing.ts +7 -0
- package/pricing.unit.test.ts +149 -0
- package/process-tree-kill.integration.test.ts +133 -0
- package/process-tree-kill.ts +325 -0
- package/process-tree-kill.unit.test.ts +265 -0
- package/progress-milestones.ts +86 -0
- package/progress-milestones.unit.test.ts +188 -0
- package/run-telemetry.ts +241 -0
- package/run-telemetry.unit.test.ts +235 -0
- package/sessions.integration.test.ts +273 -0
- package/sessions.ts +191 -0
- package/sessions.unit.test.ts +94 -0
- package/setup-github-auth.integration.test.ts +858 -0
- package/setup-github-auth.ts +297 -0
- package/setup.fixture.integration.test.ts +109 -0
- package/setup.integration.test.ts +1495 -0
- package/setup.ts +853 -0
- package/setup.unit.test.ts +286 -0
- package/shipwright-config-client.ts +36 -0
- package/shipwright-runtime-client.integration.test.ts +336 -0
- package/shipwright-runtime-client.ts +118 -0
- package/slack-account-status.unit.test.ts +251 -0
- package/slack-bolt-client.integration.test.ts +76 -0
- package/slack-chat-conversation.integration.test.ts +333 -0
- package/slack-progress.ts +510 -0
- package/slack-progress.unit.test.ts +806 -0
- package/slack-socket-mode-ping.integration.test.ts +137 -0
- package/slack-startup.ts +170 -0
- package/slack-startup.unit.test.ts +300 -0
- package/slack-thread-status-tracker.ts +70 -0
- package/slack-thread-status-tracker.unit.test.ts +118 -0
- package/slack-trial-expiry.unit.test.ts +269 -0
- package/slack.integration.test.ts +3979 -0
- package/slack.ts +1286 -0
- package/slack.unit.test.ts +1070 -0
- package/spawn-env.ts +58 -0
- package/spawn-env.unit.test.ts +96 -0
- package/startup-dm.integration.test.ts +156 -0
- package/startup-dm.ts +29 -0
- package/sync-config-clone.ts +238 -0
- package/sync-config-clone.unit.test.ts +429 -0
- package/test-env.ts +27 -0
- package/users.ts +90 -0
- package/users.unit.test.ts +108 -0
- package/voice.integration.test.ts +669 -0
- package/voice.ts +315 -0
- package/voice.unit.test.ts +105 -0
- package/workspace/BOOTSTRAP.md.template +43 -0
- package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
- package/workspace/IDENTITY.md.template +8 -0
- package/workspace/RUNTIME.md.template +193 -0
- package/workspace/RUNTIME.md.template.content.test.ts +75 -0
- package/workspace/SOUL.md.template +45 -0
- package/workspace/SPECIALTY.md.template +37 -0
- package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
- package/workspace/VOICE.md.template +8 -0
- package/workspace/mise.toml.template +12 -0
- package/workspace/requirements.txt.template +1 -0
- package/workspace-paths.ts +37 -0
- package/worktree-reaper.ts +244 -0
- package/worktree-reaper.unit.test.ts +380 -0
- package/README.md +0 -3
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for agent/src/runtime/config.ts
|
|
3
|
+
*
|
|
4
|
+
* Strategy: call createConfig(agentHome) factory directly with a temp dir.
|
|
5
|
+
* No mock.module() needed — the factory reads env vars at call time.
|
|
6
|
+
* Uses SHIPWRIGHT_* env vars only.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
10
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { createConfig } from "./config.ts";
|
|
14
|
+
|
|
15
|
+
const AGENT_HOME = join(
|
|
16
|
+
tmpdir(),
|
|
17
|
+
`shipwright-agent-config-test-${process.pid}`,
|
|
18
|
+
);
|
|
19
|
+
mkdirSync(AGENT_HOME, { recursive: true });
|
|
20
|
+
|
|
21
|
+
// Set env vars before calling createConfig
|
|
22
|
+
process.env.ANTHROPIC_MODEL = "claude-opus-4-6";
|
|
23
|
+
process.env.SHIPWRIGHT_API_URL = "https://api.shipwright.app";
|
|
24
|
+
process.env.SHIPWRIGHT_AGENT_API_KEY = "key-123";
|
|
25
|
+
process.env.SHIPWRIGHT_AGENT_ID = "agent-xyz";
|
|
26
|
+
|
|
27
|
+
const { config } = createConfig(AGENT_HOME);
|
|
28
|
+
|
|
29
|
+
afterAll(() => {
|
|
30
|
+
rmSync(AGENT_HOME, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// ─── config.claude ────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
describe("config.claude", () => {
|
|
36
|
+
test("model from ANTHROPIC_MODEL env var", () => {
|
|
37
|
+
expect(config.claude.model).toBe("claude-opus-4-6");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("fallbackModel from ANTHROPIC_FALLBACK_MODEL env var", () => {
|
|
41
|
+
process.env.ANTHROPIC_FALLBACK_MODEL = "claude-sonnet-4-6";
|
|
42
|
+
const { config: cfg } = createConfig(AGENT_HOME);
|
|
43
|
+
expect(cfg.claude.fallbackModel).toBe("claude-sonnet-4-6");
|
|
44
|
+
delete process.env.ANTHROPIC_FALLBACK_MODEL;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("fallbackModel is undefined when ANTHROPIC_FALLBACK_MODEL not set", () => {
|
|
48
|
+
delete process.env.ANTHROPIC_FALLBACK_MODEL;
|
|
49
|
+
const { config: cfg } = createConfig(AGENT_HOME);
|
|
50
|
+
expect(cfg.claude.fallbackModel).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("effortLevel from ANTHROPIC_EFFORT_LEVEL env var", () => {
|
|
54
|
+
process.env.ANTHROPIC_EFFORT_LEVEL = "xhigh";
|
|
55
|
+
const { config: cfg } = createConfig(AGENT_HOME);
|
|
56
|
+
expect(cfg.claude.effortLevel).toBe("xhigh");
|
|
57
|
+
delete process.env.ANTHROPIC_EFFORT_LEVEL;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("effortLevel is undefined when ANTHROPIC_EFFORT_LEVEL not set", () => {
|
|
61
|
+
delete process.env.ANTHROPIC_EFFORT_LEVEL;
|
|
62
|
+
const { config: cfg } = createConfig(AGENT_HOME);
|
|
63
|
+
expect(cfg.claude.effortLevel).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("anthropicApiKey from ANTHROPIC_API_KEY env var", () => {
|
|
67
|
+
process.env.ANTHROPIC_API_KEY = "sk-ant-test-key";
|
|
68
|
+
const { config: cfg } = createConfig(AGENT_HOME);
|
|
69
|
+
expect(cfg.claude.anthropicApiKey).toBe("sk-ant-test-key");
|
|
70
|
+
delete process.env.ANTHROPIC_API_KEY;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("anthropicApiKey is undefined when ANTHROPIC_API_KEY not set", () => {
|
|
74
|
+
delete process.env.ANTHROPIC_API_KEY;
|
|
75
|
+
const { config: cfg } = createConfig(AGENT_HOME);
|
|
76
|
+
expect(cfg.claude.anthropicApiKey).toBeUndefined();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("model defaults to claude-sonnet-5-5 when ANTHROPIC_MODEL not set", () => {
|
|
80
|
+
const saved = process.env.ANTHROPIC_MODEL;
|
|
81
|
+
delete process.env.ANTHROPIC_MODEL;
|
|
82
|
+
const { config: cfg } = createConfig(AGENT_HOME);
|
|
83
|
+
expect(cfg.claude.model).toBe("claude-sonnet-5-5");
|
|
84
|
+
process.env.ANTHROPIC_MODEL = saved;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("timeoutMs defaults to 1hr when SHIPWRIGHT_CLAUDE_TIMEOUT_MS not set", () => {
|
|
88
|
+
expect(config.claude.timeoutMs).toBe(3_600_000);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("timeoutMs from SHIPWRIGHT_CLAUDE_TIMEOUT_MS env var", () => {
|
|
92
|
+
process.env.SHIPWRIGHT_CLAUDE_TIMEOUT_MS = "5400000";
|
|
93
|
+
const { config: cfg } = createConfig(AGENT_HOME);
|
|
94
|
+
expect(cfg.claude.timeoutMs).toBe(5_400_000);
|
|
95
|
+
delete process.env.SHIPWRIGHT_CLAUDE_TIMEOUT_MS;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("timeoutMs falls back to default when non-numeric", () => {
|
|
99
|
+
process.env.SHIPWRIGHT_CLAUDE_TIMEOUT_MS = "not-a-number";
|
|
100
|
+
const { config: cfg } = createConfig(AGENT_HOME);
|
|
101
|
+
expect(cfg.claude.timeoutMs).toBe(3_600_000);
|
|
102
|
+
delete process.env.SHIPWRIGHT_CLAUDE_TIMEOUT_MS;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("timeoutMs falls back to default when zero or negative", () => {
|
|
106
|
+
process.env.SHIPWRIGHT_CLAUDE_TIMEOUT_MS = "0";
|
|
107
|
+
expect(createConfig(AGENT_HOME).config.claude.timeoutMs).toBe(3_600_000);
|
|
108
|
+
process.env.SHIPWRIGHT_CLAUDE_TIMEOUT_MS = "-1000";
|
|
109
|
+
expect(createConfig(AGENT_HOME).config.claude.timeoutMs).toBe(3_600_000);
|
|
110
|
+
delete process.env.SHIPWRIGHT_CLAUDE_TIMEOUT_MS;
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("timeoutMs falls back to default when non-integer", () => {
|
|
114
|
+
process.env.SHIPWRIGHT_CLAUDE_TIMEOUT_MS = "1500.5";
|
|
115
|
+
const { config: cfg } = createConfig(AGENT_HOME);
|
|
116
|
+
expect(cfg.claude.timeoutMs).toBe(3_600_000);
|
|
117
|
+
delete process.env.SHIPWRIGHT_CLAUDE_TIMEOUT_MS;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("idleTimeoutMs defaults to 25min when SHIPWRIGHT_CLAUDE_IDLE_TIMEOUT_MS not set", () => {
|
|
121
|
+
expect(config.claude.idleTimeoutMs).toBe(1_500_000);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("idleTimeoutMs from SHIPWRIGHT_CLAUDE_IDLE_TIMEOUT_MS env var", () => {
|
|
125
|
+
process.env.SHIPWRIGHT_CLAUDE_IDLE_TIMEOUT_MS = "900000";
|
|
126
|
+
const { config: cfg } = createConfig(AGENT_HOME);
|
|
127
|
+
expect(cfg.claude.idleTimeoutMs).toBe(900_000);
|
|
128
|
+
delete process.env.SHIPWRIGHT_CLAUDE_IDLE_TIMEOUT_MS;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("idleTimeoutMs falls back to default when non-numeric", () => {
|
|
132
|
+
process.env.SHIPWRIGHT_CLAUDE_IDLE_TIMEOUT_MS = "not-a-number";
|
|
133
|
+
const { config: cfg } = createConfig(AGENT_HOME);
|
|
134
|
+
expect(cfg.claude.idleTimeoutMs).toBe(1_500_000);
|
|
135
|
+
delete process.env.SHIPWRIGHT_CLAUDE_IDLE_TIMEOUT_MS;
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("idleTimeoutMs falls back to default when zero or negative", () => {
|
|
139
|
+
process.env.SHIPWRIGHT_CLAUDE_IDLE_TIMEOUT_MS = "0";
|
|
140
|
+
expect(createConfig(AGENT_HOME).config.claude.idleTimeoutMs).toBe(
|
|
141
|
+
1_500_000,
|
|
142
|
+
);
|
|
143
|
+
process.env.SHIPWRIGHT_CLAUDE_IDLE_TIMEOUT_MS = "-1000";
|
|
144
|
+
expect(createConfig(AGENT_HOME).config.claude.idleTimeoutMs).toBe(
|
|
145
|
+
1_500_000,
|
|
146
|
+
);
|
|
147
|
+
delete process.env.SHIPWRIGHT_CLAUDE_IDLE_TIMEOUT_MS;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("idleTimeoutMs falls back to default when non-integer", () => {
|
|
151
|
+
process.env.SHIPWRIGHT_CLAUDE_IDLE_TIMEOUT_MS = "1500.5";
|
|
152
|
+
const { config: cfg } = createConfig(AGENT_HOME);
|
|
153
|
+
expect(cfg.claude.idleTimeoutMs).toBe(1_500_000);
|
|
154
|
+
delete process.env.SHIPWRIGHT_CLAUDE_IDLE_TIMEOUT_MS;
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ─── config.shipwright ────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
describe("config.shipwright", () => {
|
|
161
|
+
test("apiUrl from SHIPWRIGHT_API_URL", () => {
|
|
162
|
+
expect(config.shipwright.apiUrl).toBe("https://api.shipwright.app");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("apiKey from SHIPWRIGHT_AGENT_API_KEY", () => {
|
|
166
|
+
expect(config.shipwright.apiKey).toBe("key-123");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("agentId from SHIPWRIGHT_AGENT_ID", () => {
|
|
170
|
+
expect(config.shipwright.agentId).toBe("agent-xyz");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("apiUrl is undefined when SHIPWRIGHT_API_URL not set", () => {
|
|
174
|
+
const saved = process.env.SHIPWRIGHT_API_URL;
|
|
175
|
+
delete process.env.SHIPWRIGHT_API_URL;
|
|
176
|
+
const { config: cfg } = createConfig(AGENT_HOME);
|
|
177
|
+
expect(cfg.shipwright.apiUrl).toBeUndefined();
|
|
178
|
+
process.env.SHIPWRIGHT_API_URL = saved;
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// ─── config.paths ─────────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
describe("config.paths", () => {
|
|
185
|
+
test("home is agentHome", () => {
|
|
186
|
+
expect(config.paths.home).toBe(AGENT_HOME);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("workspace is inside agentHome", () => {
|
|
190
|
+
expect(config.paths.workspace).toContain(AGENT_HOME);
|
|
191
|
+
expect(config.paths.workspace).toContain("workspace");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("sessions is agentHome/sessions.json", () => {
|
|
195
|
+
expect(config.paths.sessions).toBe(join(AGENT_HOME, "sessions.json"));
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// ─── config.slack ─────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
describe("config.slack", () => {
|
|
202
|
+
test("botToken from SLACK_BOT_TOKEN", () => {
|
|
203
|
+
process.env.SLACK_BOT_TOKEN = "xoxb-test-bot";
|
|
204
|
+
const { config: cfg } = createConfig(AGENT_HOME);
|
|
205
|
+
expect(cfg.slack.botToken).toBe("xoxb-test-bot");
|
|
206
|
+
delete process.env.SLACK_BOT_TOKEN;
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("botToken is undefined when SLACK_BOT_TOKEN not set", () => {
|
|
210
|
+
delete process.env.SLACK_BOT_TOKEN;
|
|
211
|
+
const { config: cfg } = createConfig(AGENT_HOME);
|
|
212
|
+
expect(cfg.slack.botToken).toBeUndefined();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("appToken from SLACK_APP_TOKEN", () => {
|
|
216
|
+
process.env.SLACK_APP_TOKEN = "xapp-test-token";
|
|
217
|
+
const { config: cfg } = createConfig(AGENT_HOME);
|
|
218
|
+
expect(cfg.slack.appToken).toBe("xapp-test-token");
|
|
219
|
+
delete process.env.SLACK_APP_TOKEN;
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("appToken is undefined when SLACK_APP_TOKEN not set", () => {
|
|
223
|
+
delete process.env.SLACK_APP_TOKEN;
|
|
224
|
+
const { config: cfg } = createConfig(AGENT_HOME);
|
|
225
|
+
expect(cfg.slack.appToken).toBeUndefined();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("signingSecret from SLACK_SIGNING_SECRET", () => {
|
|
229
|
+
process.env.SLACK_SIGNING_SECRET = "abc123secret";
|
|
230
|
+
const { config: cfg } = createConfig(AGENT_HOME);
|
|
231
|
+
expect(cfg.slack.signingSecret).toBe("abc123secret");
|
|
232
|
+
delete process.env.SLACK_SIGNING_SECRET;
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("signingSecret is undefined when SLACK_SIGNING_SECRET not set", () => {
|
|
236
|
+
delete process.env.SLACK_SIGNING_SECRET;
|
|
237
|
+
const { config: cfg } = createConfig(AGENT_HOME);
|
|
238
|
+
expect(cfg.slack.signingSecret).toBeUndefined();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("adminToken from SLACK_ADMIN_TOKEN", () => {
|
|
242
|
+
process.env.SLACK_ADMIN_TOKEN = "xoxp-admin-token";
|
|
243
|
+
const { config: cfg } = createConfig(AGENT_HOME);
|
|
244
|
+
expect(cfg.slack.adminToken).toBe("xoxp-admin-token");
|
|
245
|
+
delete process.env.SLACK_ADMIN_TOKEN;
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("adminToken is undefined when SLACK_ADMIN_TOKEN not set", () => {
|
|
249
|
+
delete process.env.SLACK_ADMIN_TOKEN;
|
|
250
|
+
const { config: cfg } = createConfig(AGENT_HOME);
|
|
251
|
+
expect(cfg.slack.adminToken).toBeUndefined();
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// ─── config.alerts ────────────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
describe("config.alerts", () => {
|
|
258
|
+
test("channel from SLACK_ALERT_CHANNEL", () => {
|
|
259
|
+
process.env.SLACK_ALERT_CHANNEL = "#alerts";
|
|
260
|
+
const { config: cfg } = createConfig(AGENT_HOME);
|
|
261
|
+
expect(cfg.alerts.channel).toBe("#alerts");
|
|
262
|
+
delete process.env.SLACK_ALERT_CHANNEL;
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("channel is undefined when SLACK_ALERT_CHANNEL not set", () => {
|
|
266
|
+
delete process.env.SLACK_ALERT_CHANNEL;
|
|
267
|
+
const { config: cfg } = createConfig(AGENT_HOME);
|
|
268
|
+
expect(cfg.alerts.channel).toBeUndefined();
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// ─── config.owner ─────────────────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
describe("config.owner", () => {
|
|
275
|
+
test("user from SLACK_OWNER_USER", () => {
|
|
276
|
+
process.env.SLACK_OWNER_USER = "U012AB3CD";
|
|
277
|
+
const { config: cfg } = createConfig(AGENT_HOME);
|
|
278
|
+
expect(cfg.owner.user).toBe("U012AB3CD");
|
|
279
|
+
delete process.env.SLACK_OWNER_USER;
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("user is undefined when SLACK_OWNER_USER not set", () => {
|
|
283
|
+
delete process.env.SLACK_OWNER_USER;
|
|
284
|
+
const { config: cfg } = createConfig(AGENT_HOME);
|
|
285
|
+
expect(cfg.owner.user).toBeUndefined();
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// ─── config.voice ─────────────────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
describe("config.voice", () => {
|
|
292
|
+
test("groqApiKey from GROQ_API_KEY", () => {
|
|
293
|
+
process.env.GROQ_API_KEY = "gsk-test-key";
|
|
294
|
+
const { config: cfg } = createConfig(AGENT_HOME);
|
|
295
|
+
expect(cfg.voice.groqApiKey).toBe("gsk-test-key");
|
|
296
|
+
delete process.env.GROQ_API_KEY;
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("groqApiKey is undefined when GROQ_API_KEY not set", () => {
|
|
300
|
+
delete process.env.GROQ_API_KEY;
|
|
301
|
+
const { config: cfg } = createConfig(AGENT_HOME);
|
|
302
|
+
expect(cfg.voice.groqApiKey).toBeUndefined();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("elevenLabsApiKey from ELEVENLABS_API_KEY", () => {
|
|
306
|
+
process.env.ELEVENLABS_API_KEY = "eleven-test-key";
|
|
307
|
+
const { config: cfg } = createConfig(AGENT_HOME);
|
|
308
|
+
expect(cfg.voice.elevenLabsApiKey).toBe("eleven-test-key");
|
|
309
|
+
delete process.env.ELEVENLABS_API_KEY;
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("elevenLabsApiKey is undefined when ELEVENLABS_API_KEY not set", () => {
|
|
313
|
+
delete process.env.ELEVENLABS_API_KEY;
|
|
314
|
+
const { config: cfg } = createConfig(AGENT_HOME);
|
|
315
|
+
expect(cfg.voice.elevenLabsApiKey).toBeUndefined();
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("voiceId from ELEVENLABS_VOICE_ID", () => {
|
|
319
|
+
process.env.ELEVENLABS_VOICE_ID = "voice-abc";
|
|
320
|
+
const { config: cfg } = createConfig(AGENT_HOME);
|
|
321
|
+
expect(cfg.voice.voiceId).toBe("voice-abc");
|
|
322
|
+
delete process.env.ELEVENLABS_VOICE_ID;
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("voiceId is undefined when ELEVENLABS_VOICE_ID not set", () => {
|
|
326
|
+
delete process.env.ELEVENLABS_VOICE_ID;
|
|
327
|
+
const { config: cfg } = createConfig(AGENT_HOME);
|
|
328
|
+
expect(cfg.voice.voiceId).toBeUndefined();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("piperVoice from PIPER_VOICE", () => {
|
|
332
|
+
process.env.PIPER_VOICE = "en_GB-alan-medium";
|
|
333
|
+
const { config: cfg } = createConfig(AGENT_HOME);
|
|
334
|
+
expect(cfg.voice.piperVoice).toBe("en_GB-alan-medium");
|
|
335
|
+
delete process.env.PIPER_VOICE;
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("piperVoice is undefined when PIPER_VOICE not set", () => {
|
|
339
|
+
delete process.env.PIPER_VOICE;
|
|
340
|
+
const { config: cfg } = createConfig(AGENT_HOME);
|
|
341
|
+
expect(cfg.voice.piperVoice).toBeUndefined();
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("whisperServiceUrl from WHISPER_SERVICE_URL", () => {
|
|
345
|
+
process.env.WHISPER_SERVICE_URL = "http://localhost:9000";
|
|
346
|
+
const { config: cfg } = createConfig(AGENT_HOME);
|
|
347
|
+
expect(cfg.voice.whisperServiceUrl).toBe("http://localhost:9000");
|
|
348
|
+
delete process.env.WHISPER_SERVICE_URL;
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("whisperServiceUrl is undefined when WHISPER_SERVICE_URL not set", () => {
|
|
352
|
+
delete process.env.WHISPER_SERVICE_URL;
|
|
353
|
+
const { config: cfg } = createConfig(AGENT_HOME);
|
|
354
|
+
expect(cfg.voice.whisperServiceUrl).toBeUndefined();
|
|
355
|
+
});
|
|
356
|
+
});
|
package/context-stamp.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/context-stamp.ts
|
|
3
|
+
*
|
|
4
|
+
* Fingerprints the markdown Claude Code loads into every session from the
|
|
5
|
+
* agent's workspace — the workspace `CLAUDE.md`, the files it `@`-imports,
|
|
6
|
+
* and every `.claude/rules/*.md` without a `paths:` frontmatter (path-scoped
|
|
7
|
+
* rules load on demand and are excluded) — plus the plugin version, since
|
|
8
|
+
* the plugin's skill listing is also part of the always-loaded context.
|
|
9
|
+
*
|
|
10
|
+
* The resulting `contextFingerprint` is stamped onto each cron run so token
|
|
11
|
+
* and outcome series can be grouped by *what the model was given*, not by
|
|
12
|
+
* when a change merged. Two runs with the same fingerprint saw the same
|
|
13
|
+
* always-loaded text; a CLAUDE.md edit produces a new fingerprint and the
|
|
14
|
+
* prompt-audit patrol reads the before/after series by that key.
|
|
15
|
+
*
|
|
16
|
+
* Pure core (`computeContextStamp`) with injected file access; the
|
|
17
|
+
* production reader (`createContextStampReader`) wraps `node:fs` and is
|
|
18
|
+
* cheap enough to call once per run — the always-loaded set is a handful of
|
|
19
|
+
* small files.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createHash } from "node:crypto";
|
|
23
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
24
|
+
import { join, resolve } from "node:path";
|
|
25
|
+
|
|
26
|
+
export interface RunContextStamp {
|
|
27
|
+
/** First 12 hex chars of sha256 over the always-loaded set + plugin version. */
|
|
28
|
+
contextFingerprint: string;
|
|
29
|
+
pluginVersion: string | null;
|
|
30
|
+
claudeCodeVersion: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ContextStampDeps {
|
|
34
|
+
/** Workspace root — the cwd Claude Code is spawned in. */
|
|
35
|
+
workspace: string;
|
|
36
|
+
/** Returns file contents, or null when the file is missing/unreadable. */
|
|
37
|
+
readFile: (absPath: string) => string | null;
|
|
38
|
+
/** Lists `.md` files (absolute paths) under `<workspace>/.claude/rules`, recursively. */
|
|
39
|
+
listRuleFiles: () => string[];
|
|
40
|
+
pluginVersion: () => string | null;
|
|
41
|
+
claudeCodeVersion: () => string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Fenced code blocks and inline code spans are skipped by Claude Code's import parser. */
|
|
45
|
+
const FENCE_RE = /```[\s\S]*?```/g;
|
|
46
|
+
const INLINE_CODE_RE = /`[^`\n]*`/g;
|
|
47
|
+
const IMPORT_RE = /(?:^|\s)@((?:\\ |[^\s`])+)/g;
|
|
48
|
+
|
|
49
|
+
/** Extracts `@path` imports the way Claude Code resolves them (one hop, relative to the file). */
|
|
50
|
+
export function extractImports(content: string): string[] {
|
|
51
|
+
const stripped = content.replace(FENCE_RE, "").replace(INLINE_CODE_RE, "");
|
|
52
|
+
const out: string[] = [];
|
|
53
|
+
for (const m of stripped.matchAll(IMPORT_RE)) {
|
|
54
|
+
const raw = m[1];
|
|
55
|
+
if (!raw) continue;
|
|
56
|
+
// Email-ish tokens ("user@host") never match because the regex requires
|
|
57
|
+
// whitespace or line start before the "@"; strip trailing punctuation.
|
|
58
|
+
const cleaned = raw.replace(/\\ /g, " ").replace(/[.,;:)]+$/, "");
|
|
59
|
+
if (cleaned.length > 0) out.push(cleaned);
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** True when the rule file's YAML frontmatter declares a `paths:` key (on-demand rule). */
|
|
65
|
+
export function hasPathsFrontmatter(content: string): boolean {
|
|
66
|
+
if (!content.startsWith("---")) return false;
|
|
67
|
+
const end = content.indexOf("\n---", 3);
|
|
68
|
+
if (end === -1) return false;
|
|
69
|
+
const frontmatter = content.slice(3, end);
|
|
70
|
+
return /^\s*paths\s*:/m.test(frontmatter);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function computeContextStamp(deps: ContextStampDeps): RunContextStamp {
|
|
74
|
+
const hash = createHash("sha256");
|
|
75
|
+
const claudeMdPath = join(deps.workspace, "CLAUDE.md");
|
|
76
|
+
const claudeMd = deps.readFile(claudeMdPath);
|
|
77
|
+
hash.update("CLAUDE.md\0");
|
|
78
|
+
hash.update(claudeMd ?? "");
|
|
79
|
+
hash.update("\0");
|
|
80
|
+
|
|
81
|
+
if (claudeMd !== null) {
|
|
82
|
+
for (const rel of extractImports(claudeMd)) {
|
|
83
|
+
const abs = resolve(deps.workspace, rel);
|
|
84
|
+
hash.update(`import:${rel}\0`);
|
|
85
|
+
hash.update(deps.readFile(abs) ?? "");
|
|
86
|
+
hash.update("\0");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const rules = deps
|
|
91
|
+
.listRuleFiles()
|
|
92
|
+
.map((abs) => ({ abs, content: deps.readFile(abs) }))
|
|
93
|
+
.filter(
|
|
94
|
+
(r): r is { abs: string; content: string } =>
|
|
95
|
+
r.content !== null && !hasPathsFrontmatter(r.content),
|
|
96
|
+
)
|
|
97
|
+
.sort((a, b) => (a.abs < b.abs ? -1 : a.abs > b.abs ? 1 : 0));
|
|
98
|
+
for (const rule of rules) {
|
|
99
|
+
hash.update(`rule:${rule.abs.slice(deps.workspace.length)}\0`);
|
|
100
|
+
hash.update(rule.content);
|
|
101
|
+
hash.update("\0");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const pluginVersion = deps.pluginVersion();
|
|
105
|
+
hash.update(`plugin:${pluginVersion ?? ""}\0`);
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
contextFingerprint: hash.digest("hex").slice(0, 12),
|
|
109
|
+
pluginVersion,
|
|
110
|
+
claudeCodeVersion: deps.claudeCodeVersion(),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Fail-soft wrapper: any error from the injected deps (unreadable rules dir,
|
|
116
|
+
* throwing version probe) yields `null` — an unstamped run — never a throw.
|
|
117
|
+
*/
|
|
118
|
+
export function tryComputeContextStamp(
|
|
119
|
+
deps: ContextStampDeps,
|
|
120
|
+
): RunContextStamp | null {
|
|
121
|
+
try {
|
|
122
|
+
return computeContextStamp(deps);
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function listMarkdownRecursive(dir: string): string[] {
|
|
129
|
+
if (!existsSync(dir)) return [];
|
|
130
|
+
const out: string[] = [];
|
|
131
|
+
for (const entry of readdirSync(dir)) {
|
|
132
|
+
const abs = join(dir, entry);
|
|
133
|
+
let isDir = false;
|
|
134
|
+
try {
|
|
135
|
+
isDir = statSync(abs).isDirectory();
|
|
136
|
+
} catch {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (isDir) out.push(...listMarkdownRecursive(abs));
|
|
140
|
+
else if (entry.endsWith(".md")) out.push(abs);
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function readFileOrNull(absPath: string): string | null {
|
|
146
|
+
try {
|
|
147
|
+
return readFileSync(absPath, "utf-8");
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Reads `version` from a plugin.json / package.json-shaped file, or null. */
|
|
154
|
+
function readVersionFile(absPath: string): string | null {
|
|
155
|
+
const raw = readFileOrNull(absPath);
|
|
156
|
+
if (raw === null) return null;
|
|
157
|
+
try {
|
|
158
|
+
const parsed = JSON.parse(raw) as { version?: unknown };
|
|
159
|
+
return typeof parsed.version === "string" ? parsed.version : null;
|
|
160
|
+
} catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
interface ContextStampReaderOptions {
|
|
166
|
+
workspace: string;
|
|
167
|
+
/** Path to the shipwright plugin's `plugin.json` (its `version` is the plugin version). */
|
|
168
|
+
pluginJsonPath: string;
|
|
169
|
+
claudeCodeVersion: () => string | null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Production reader: `node:fs`-backed `computeContextStamp`. Never throws —
|
|
174
|
+
* a missing workspace or plugin manifest just fingerprints as empty, and any
|
|
175
|
+
* unexpected read error yields `null` (unstamped).
|
|
176
|
+
*/
|
|
177
|
+
export function createContextStampReader(
|
|
178
|
+
opts: ContextStampReaderOptions,
|
|
179
|
+
): () => RunContextStamp | null {
|
|
180
|
+
return () =>
|
|
181
|
+
tryComputeContextStamp({
|
|
182
|
+
workspace: opts.workspace,
|
|
183
|
+
readFile: readFileOrNull,
|
|
184
|
+
listRuleFiles: () =>
|
|
185
|
+
listMarkdownRecursive(join(opts.workspace, ".claude", "rules")),
|
|
186
|
+
pluginVersion: () => readVersionFile(opts.pluginJsonPath),
|
|
187
|
+
claudeCodeVersion: opts.claudeCodeVersion,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
type ContextStampDeps,
|
|
4
|
+
computeContextStamp,
|
|
5
|
+
extractImports,
|
|
6
|
+
hasPathsFrontmatter,
|
|
7
|
+
tryComputeContextStamp,
|
|
8
|
+
} from "./context-stamp.ts";
|
|
9
|
+
|
|
10
|
+
const WORKSPACE = "/ws";
|
|
11
|
+
|
|
12
|
+
function makeDeps(
|
|
13
|
+
files: Record<string, string>,
|
|
14
|
+
overrides: Partial<ContextStampDeps> = {},
|
|
15
|
+
): ContextStampDeps {
|
|
16
|
+
return {
|
|
17
|
+
workspace: WORKSPACE,
|
|
18
|
+
readFile: (abs) => files[abs] ?? null,
|
|
19
|
+
listRuleFiles: () =>
|
|
20
|
+
Object.keys(files).filter((p) =>
|
|
21
|
+
p.startsWith(`${WORKSPACE}/.claude/rules/`),
|
|
22
|
+
),
|
|
23
|
+
pluginVersion: () => "1.363.0",
|
|
24
|
+
claudeCodeVersion: () => "2.1.285",
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("extractImports", () => {
|
|
30
|
+
test("finds @path imports and ignores code spans, fences, and emails", () => {
|
|
31
|
+
const content = [
|
|
32
|
+
"# Title",
|
|
33
|
+
"@SOUL.md",
|
|
34
|
+
"- identity @IDENTITY.md and voice @VOICE.md.",
|
|
35
|
+
"Mention `@README` literally.",
|
|
36
|
+
"```",
|
|
37
|
+
"@NOT_IMPORTED.md",
|
|
38
|
+
"```",
|
|
39
|
+
"Contact ops@example.com",
|
|
40
|
+
"@Design\\ Docs/api.md",
|
|
41
|
+
].join("\n");
|
|
42
|
+
expect(extractImports(content)).toEqual([
|
|
43
|
+
"SOUL.md",
|
|
44
|
+
"IDENTITY.md",
|
|
45
|
+
"VOICE.md",
|
|
46
|
+
"Design Docs/api.md",
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("hasPathsFrontmatter", () => {
|
|
52
|
+
test("detects a paths key in frontmatter only", () => {
|
|
53
|
+
expect(hasPathsFrontmatter("---\npaths:\n - src/**\n---\nbody")).toBe(
|
|
54
|
+
true,
|
|
55
|
+
);
|
|
56
|
+
expect(hasPathsFrontmatter("---\ndescription: x\n---\npaths: not fm")).toBe(
|
|
57
|
+
false,
|
|
58
|
+
);
|
|
59
|
+
expect(hasPathsFrontmatter("no frontmatter\npaths:")).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("computeContextStamp", () => {
|
|
64
|
+
const base = {
|
|
65
|
+
[`${WORKSPACE}/CLAUDE.md`]: "# Agent\n@SOUL.md\n",
|
|
66
|
+
[`${WORKSPACE}/SOUL.md`]: "be kind",
|
|
67
|
+
[`${WORKSPACE}/.claude/rules/always.md`]: "always rule",
|
|
68
|
+
[`${WORKSPACE}/.claude/rules/scoped.md`]:
|
|
69
|
+
"---\npaths:\n - x/**\n---\nscoped",
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
test("is stable for identical inputs and 12 hex chars long", () => {
|
|
73
|
+
const a = computeContextStamp(makeDeps(base));
|
|
74
|
+
const b = computeContextStamp(makeDeps(base));
|
|
75
|
+
expect(a.contextFingerprint).toMatch(/^[0-9a-f]{12}$/);
|
|
76
|
+
expect(a).toEqual(b);
|
|
77
|
+
expect(a.pluginVersion).toBe("1.363.0");
|
|
78
|
+
expect(a.claudeCodeVersion).toBe("2.1.285");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("changes when CLAUDE.md, an import, an always-rule, or the plugin version changes", () => {
|
|
82
|
+
const ref = computeContextStamp(makeDeps(base)).contextFingerprint;
|
|
83
|
+
const edits: Array<Record<string, string>> = [
|
|
84
|
+
{ [`${WORKSPACE}/CLAUDE.md`]: "# Agent v2\n@SOUL.md\n" },
|
|
85
|
+
{ [`${WORKSPACE}/SOUL.md`]: "be kinder" },
|
|
86
|
+
{ [`${WORKSPACE}/.claude/rules/always.md`]: "always rule v2" },
|
|
87
|
+
];
|
|
88
|
+
for (const edit of edits) {
|
|
89
|
+
const fp = computeContextStamp(
|
|
90
|
+
makeDeps({ ...base, ...edit }),
|
|
91
|
+
).contextFingerprint;
|
|
92
|
+
expect(fp).not.toBe(ref);
|
|
93
|
+
}
|
|
94
|
+
const bumped = computeContextStamp(
|
|
95
|
+
makeDeps(base, { pluginVersion: () => "1.364.0" }),
|
|
96
|
+
).contextFingerprint;
|
|
97
|
+
expect(bumped).not.toBe(ref);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("ignores path-scoped rules, which load on demand", () => {
|
|
101
|
+
const ref = computeContextStamp(makeDeps(base)).contextFingerprint;
|
|
102
|
+
const edited = computeContextStamp(
|
|
103
|
+
makeDeps({
|
|
104
|
+
...base,
|
|
105
|
+
[`${WORKSPACE}/.claude/rules/scoped.md`]:
|
|
106
|
+
"---\npaths:\n - x/**\n---\nscoped v2",
|
|
107
|
+
}),
|
|
108
|
+
).contextFingerprint;
|
|
109
|
+
expect(edited).toBe(ref);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("rule order does not affect the fingerprint", () => {
|
|
113
|
+
const files = {
|
|
114
|
+
...base,
|
|
115
|
+
[`${WORKSPACE}/.claude/rules/b.md`]: "b",
|
|
116
|
+
[`${WORKSPACE}/.claude/rules/a.md`]: "a",
|
|
117
|
+
};
|
|
118
|
+
const forward = computeContextStamp(makeDeps(files)).contextFingerprint;
|
|
119
|
+
const reversed = computeContextStamp(
|
|
120
|
+
makeDeps(files, {
|
|
121
|
+
listRuleFiles: () =>
|
|
122
|
+
Object.keys(files)
|
|
123
|
+
.filter((p) => p.includes("/.claude/rules/"))
|
|
124
|
+
.reverse(),
|
|
125
|
+
}),
|
|
126
|
+
).contextFingerprint;
|
|
127
|
+
expect(reversed).toBe(forward);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("a workspace with no CLAUDE.md still produces a stamp", () => {
|
|
131
|
+
const stamp = computeContextStamp(
|
|
132
|
+
makeDeps(
|
|
133
|
+
{},
|
|
134
|
+
{ pluginVersion: () => null, claudeCodeVersion: () => null },
|
|
135
|
+
),
|
|
136
|
+
);
|
|
137
|
+
expect(stamp.contextFingerprint).toMatch(/^[0-9a-f]{12}$/);
|
|
138
|
+
expect(stamp.pluginVersion).toBeNull();
|
|
139
|
+
expect(stamp.claudeCodeVersion).toBeNull();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("read errors yield an unstamped result instead of throwing", () => {
|
|
143
|
+
const throwing = () => {
|
|
144
|
+
throw new Error("EACCES");
|
|
145
|
+
};
|
|
146
|
+
expect(
|
|
147
|
+
tryComputeContextStamp(makeDeps({}, { listRuleFiles: throwing })),
|
|
148
|
+
).toBeNull();
|
|
149
|
+
expect(
|
|
150
|
+
tryComputeContextStamp(makeDeps({}, { readFile: throwing })),
|
|
151
|
+
).toBeNull();
|
|
152
|
+
expect(
|
|
153
|
+
tryComputeContextStamp(makeDeps({}, { claudeCodeVersion: throwing })),
|
|
154
|
+
).toBeNull();
|
|
155
|
+
expect(tryComputeContextStamp(makeDeps({}))?.contextFingerprint).toMatch(
|
|
156
|
+
/^[0-9a-f]{12}$/,
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
});
|