wayfind 2.0.44 → 2.0.46

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.
@@ -27,6 +27,9 @@ Once both steps complete:
27
27
  6. Ask me if I want to set up team context (/wayfind:init-team) for shared journals,
28
28
  digests, and product state
29
29
  7. Let me know that anonymous usage telemetry is enabled by default (set TEAM_CONTEXT_TELEMETRY=false to opt out)
30
+ 8. If I'm joining an existing team, check if there's a container_endpoint in my
31
+ context.json — if so, my MCP server will automatically proxy semantic search
32
+ to the team's container. No extra setup needed.
30
33
  ```
31
34
 
32
35
  ---
@@ -36,10 +39,12 @@ Once both steps complete:
36
39
  The CLI install creates:
37
40
  - `~/.claude/memory/` and `~/.claude/memory/journal/`
38
41
  - `~/.claude/global-state.md` (your persistent index)
42
+ - `~/.claude/team-context/context.json` (team registry for multi-team support)
39
43
 
40
44
  The plugin provides:
41
45
  - SessionStart and Stop hooks (context loading, decision extraction)
42
46
  - Slash commands: `/wayfind:init-memory`, `/wayfind:init-team`, `/wayfind:journal`, `/wayfind:doctor`, `/wayfind:standup`
47
+ - MCP server (`wayfind-mcp`) — registered automatically, gives any MCP-compatible AI tool access to your team's context
43
48
 
44
49
  After the paste, Claude will walk you through filling in your preferences. From
45
50
  then on, every session in every repo will start with full context of where you
@@ -100,6 +105,27 @@ Claude will create `.claude/team-state.md` (shared) and `.claude/personal-state.
100
105
 
101
106
  ---
102
107
 
108
+ ## Container deployment (for teams)
109
+
110
+ One team member (the team owner) runs a Docker container that provides:
111
+ - Slack bot, automated digests, signal connectors
112
+ - Semantic search API for the whole team's content
113
+ - API key auto-rotation (daily, committed to team-context repo)
114
+
115
+ Other team members don't need Docker — their local MCP server automatically
116
+ proxies search queries to the container via the shared API key.
117
+
118
+ ```bash
119
+ wayfind deploy --team <teamId> # Scaffold config
120
+ # Edit deploy/.env with your Anthropic key
121
+ cd deploy && docker compose up -d # Start container
122
+ wayfind deploy set-endpoint http://your-hostname:3141 --team <teamId> # Set endpoint for team
123
+ ```
124
+
125
+ Team members pull the team-context repo to get the API key and endpoint config.
126
+
127
+ ---
128
+
103
129
  ## For Cursor users
104
130
 
105
131
  ```
@@ -117,11 +143,11 @@ https://github.com/usewayfind/wayfind
117
143
 
118
144
  To install a specific version:
119
145
  ```
120
- npm install -g wayfind@2.0.15
146
+ npm install -g wayfind@2.0.45
121
147
  wayfind init
122
148
  ```
123
149
 
124
150
  Or via the shell installer with a pinned version:
125
151
  ```
126
- WAYFIND_VERSION=v2.0.15 bash <(curl -fsSL https://raw.githubusercontent.com/usewayfind/wayfind/main/install.sh)
152
+ WAYFIND_VERSION=v2.0.45 bash <(curl -fsSL https://raw.githubusercontent.com/usewayfind/wayfind/main/install.sh)
127
153
  ```
package/README.md CHANGED
@@ -145,12 +145,26 @@ wayfind pull --all # All configured channels
145
145
  | `wayfind journal sync` | Sync journals to team repo |
146
146
  | `wayfind onboard <repo>` | Generate onboarding context pack |
147
147
  | `wayfind deploy init` | Scaffold Docker deployment |
148
+ | `wayfind deploy --team <id>` | Scaffold per-team Docker deployment |
149
+ | `wayfind deploy set-endpoint <url>` | Set container endpoint for team search |
150
+ | `wayfind deploy list` | List running team containers |
151
+ | `wayfind deploy status` | Check container health |
148
152
  | `wayfind migrate-to-plugin` | Remove old hooks (after plugin install) |
149
153
 
150
154
  Run `wayfind help` for the full list.
151
155
 
152
156
  ---
153
157
 
158
+ ## MCP Server
159
+
160
+ Wayfind includes an MCP server (`wayfind-mcp`) that exposes team context to any MCP-compatible AI tool.
161
+
162
+ **Tools:** `search_context`, `get_entry`, `list_recent`, `get_signals`, `get_team_status`, `get_personas`, `record_feedback`, `add_context`
163
+
164
+ Auto-registered during `wayfind init`. When a team container is running, the local MCP server proxies semantic search to it automatically — no config needed beyond the team-context repo.
165
+
166
+ ---
167
+
154
168
  ## Environment Variables
155
169
 
156
170
  ### For digests and bot
@@ -171,6 +185,8 @@ Run `wayfind help` for the full list.
171
185
  | `TEAM_CONTEXT_DIGEST_SCHEDULE` | Cron schedule (default: `0 8 * * 1` — Monday 8am) |
172
186
  | `TEAM_CONTEXT_EXCLUDE_REPOS` | Repos to exclude from digests |
173
187
  | `TEAM_CONTEXT_TELEMETRY` | `true` for anonymous usage telemetry |
188
+ | `TEAM_CONTEXT_NO_SLACK` | Run container without Slack integration (set to `1`) |
189
+ | `TEAM_CONTEXT_KEY_ROTATE_SCHEDULE` | API key rotation cron (default: `0 2 * * *`) |
174
190
 
175
191
  ---
176
192
 
@@ -182,6 +198,7 @@ Run `wayfind help` for the full list.
182
198
  | Claude Code | Full support (npm) | `wayfind init` |
183
199
  | Cursor | Session protocol | `wayfind init-cursor` |
184
200
  | Generic | Manual | See `specializations/generic/` |
201
+ | Any MCP client | Full support (MCP) | `wayfind init` auto-registers |
185
202
 
186
203
  ---
187
204
 
@@ -198,6 +215,8 @@ Everything that runs on your machine is open source (Apache 2.0).
198
215
  | Digest generation (your API key) | |
199
216
  | Slack bot (self-hosted) | |
200
217
  | Multi-team support | |
218
+ | MCP server (local + container proxy) | |
219
+ | Per-team content store isolation | |
201
220
 
202
221
  See [LICENSING.md](LICENSING.md) for details.
203
222
 
package/bin/mcp-server.js CHANGED
@@ -30,8 +30,199 @@ const path = require('path');
30
30
  const fs = require('fs');
31
31
  const contentStore = require('./content-store.js');
32
32
 
33
+ const http = require('http');
34
+
33
35
  const pkg = require('../package.json');
34
36
 
37
+ // ── Container proxy ─────────────────────────────────────────────────────────
38
+
39
+ const HOME = process.env.HOME || process.env.USERPROFILE;
40
+ const WAYFIND_DIR = HOME ? path.join(HOME, '.claude', 'team-context') : null;
41
+
42
+ /**
43
+ * Read context.json to find the active team's container_endpoint.
44
+ */
45
+ function getContainerEndpoint() {
46
+ if (!WAYFIND_DIR) return null;
47
+ const configPath = path.join(WAYFIND_DIR, 'context.json');
48
+ try {
49
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
50
+ // Resolve active team: repo binding → default
51
+ const teamId = getActiveTeamId(config);
52
+ if (!teamId || !config.teams || !config.teams[teamId]) return null;
53
+ return config.teams[teamId].container_endpoint || null;
54
+ } catch (_) {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Resolve active team ID from context.json (simplified — mirrors team-context.js logic).
61
+ */
62
+ function getActiveTeamId(config) {
63
+ // Check repo-level binding first
64
+ try {
65
+ const bindingFile = path.join(process.cwd(), '.claude', 'wayfind.json');
66
+ if (fs.existsSync(bindingFile)) {
67
+ const binding = JSON.parse(fs.readFileSync(bindingFile, 'utf8'));
68
+ if (binding.team_id) return binding.team_id;
69
+ }
70
+ } catch (_) {}
71
+ return config.default || null;
72
+ }
73
+
74
+ /**
75
+ * Read the shared API key from the team-context repo.
76
+ */
77
+ function readApiKey() {
78
+ if (!WAYFIND_DIR) return null;
79
+ try {
80
+ const config = JSON.parse(fs.readFileSync(path.join(WAYFIND_DIR, 'context.json'), 'utf8'));
81
+ const teamId = getActiveTeamId(config);
82
+ if (!teamId || !config.teams || !config.teams[teamId]) return null;
83
+ const teamPath = config.teams[teamId].path;
84
+ if (!teamPath) return null;
85
+ const keyFile = path.join(teamPath, '.wayfind-api-key');
86
+ return fs.readFileSync(keyFile, 'utf8').trim();
87
+ } catch (_) {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ // Cache for API key (re-read from disk on 401)
93
+ let cachedApiKey = null;
94
+
95
+ /**
96
+ * HTTP POST to the container's search API.
97
+ * Returns parsed JSON or null on failure.
98
+ */
99
+ function containerPost(endpoint, apiKey, body) {
100
+ return new Promise((resolve) => {
101
+ try {
102
+ const url = new URL(endpoint);
103
+ const postData = JSON.stringify(body);
104
+ const req = http.request({
105
+ hostname: url.hostname,
106
+ port: url.port || 80,
107
+ path: url.pathname,
108
+ method: 'POST',
109
+ timeout: 10000,
110
+ headers: {
111
+ 'Content-Type': 'application/json',
112
+ 'Content-Length': Buffer.byteLength(postData),
113
+ 'Authorization': `Bearer ${apiKey}`,
114
+ },
115
+ }, (res) => {
116
+ let data = '';
117
+ res.on('data', (chunk) => { data += chunk; });
118
+ res.on('end', () => {
119
+ resolve({ status: res.statusCode, body: data });
120
+ });
121
+ });
122
+ req.on('error', () => resolve(null));
123
+ req.on('timeout', () => { req.destroy(); resolve(null); });
124
+ req.write(postData);
125
+ req.end();
126
+ } catch (_) {
127
+ resolve(null);
128
+ }
129
+ });
130
+ }
131
+
132
+ /**
133
+ * HTTP GET from the container's entry API.
134
+ */
135
+ function containerGet(endpoint, apiKey) {
136
+ return new Promise((resolve) => {
137
+ try {
138
+ const url = new URL(endpoint);
139
+ const req = http.request({
140
+ hostname: url.hostname,
141
+ port: url.port || 80,
142
+ path: url.pathname + url.search,
143
+ method: 'GET',
144
+ timeout: 10000,
145
+ headers: {
146
+ 'Authorization': `Bearer ${apiKey}`,
147
+ },
148
+ }, (res) => {
149
+ let data = '';
150
+ res.on('data', (chunk) => { data += chunk; });
151
+ res.on('end', () => {
152
+ resolve({ status: res.statusCode, body: data });
153
+ });
154
+ });
155
+ req.on('error', () => resolve(null));
156
+ req.on('timeout', () => { req.destroy(); resolve(null); });
157
+ req.end();
158
+ } catch (_) {
159
+ resolve(null);
160
+ }
161
+ });
162
+ }
163
+
164
+ /**
165
+ * Try to proxy a search request to the container.
166
+ * On 401, re-reads the API key from disk and retries once.
167
+ * Returns parsed result or null if container unreachable/unavailable.
168
+ */
169
+ async function proxySearch(body) {
170
+ const endpoint = getContainerEndpoint();
171
+ if (!endpoint) return null;
172
+
173
+ if (!cachedApiKey) cachedApiKey = readApiKey();
174
+ if (!cachedApiKey) return null;
175
+
176
+ const searchUrl = `${endpoint}/api/search`;
177
+ let result = await containerPost(searchUrl, cachedApiKey, body);
178
+
179
+ // On 401, re-read key (may have been rotated) and retry once
180
+ if (result && result.status === 401) {
181
+ process.stderr.write('Container returned 401 — re-reading API key...\n');
182
+ cachedApiKey = readApiKey();
183
+ if (!cachedApiKey) return null;
184
+ result = await containerPost(searchUrl, cachedApiKey, body);
185
+ }
186
+
187
+ if (!result || result.status !== 200) return null;
188
+
189
+ try {
190
+ return JSON.parse(result.body);
191
+ } catch (_) {
192
+ return null;
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Try to proxy an entry retrieval to the container.
198
+ * Same 401-retry logic as proxySearch.
199
+ */
200
+ async function proxyGetEntry(id) {
201
+ const endpoint = getContainerEndpoint();
202
+ if (!endpoint) return null;
203
+
204
+ if (!cachedApiKey) cachedApiKey = readApiKey();
205
+ if (!cachedApiKey) return null;
206
+
207
+ const entryUrl = `${endpoint}/api/entry/${encodeURIComponent(id)}`;
208
+ let result = await containerGet(entryUrl, cachedApiKey);
209
+
210
+ if (result && result.status === 401) {
211
+ process.stderr.write('Container returned 401 — re-reading API key...\n');
212
+ cachedApiKey = readApiKey();
213
+ if (!cachedApiKey) return null;
214
+ result = await containerGet(entryUrl, cachedApiKey);
215
+ }
216
+
217
+ if (!result || result.status !== 200) return null;
218
+
219
+ try {
220
+ return JSON.parse(result.body);
221
+ } catch (_) {
222
+ return null;
223
+ }
224
+ }
225
+
35
226
  // ── Tool definitions ─────────────────────────────────────────────────────────
36
227
 
37
228
  const TOOLS = [
@@ -137,8 +328,18 @@ const TOOLS = [
137
328
 
138
329
  async function handleSearchContext(args) {
139
330
  const { query, limit = 10, repo, since, mode } = args;
140
- const opts = { limit, repo, since };
141
331
 
332
+ // Try container first for semantic search (has embeddings for full team)
333
+ if (mode !== 'text') {
334
+ const containerResult = await proxySearch({ query, limit, repo, since, mode });
335
+ if (containerResult && containerResult.found > 0) {
336
+ containerResult.source = 'container';
337
+ return containerResult;
338
+ }
339
+ }
340
+
341
+ // Fall back to local search
342
+ const opts = { limit, repo, since };
142
343
  let results;
143
344
  if (mode === 'text') {
144
345
  results = contentStore.searchText(query, opts);
@@ -147,11 +348,12 @@ async function handleSearchContext(args) {
147
348
  }
148
349
 
149
350
  if (!results || results.length === 0) {
150
- return { found: 0, results: [], hint: 'No matches. Try a broader query or check wayfind reindex.' };
351
+ return { found: 0, results: [], source: 'local', hint: 'No matches. Try a broader query or check wayfind reindex.' };
151
352
  }
152
353
 
153
354
  return {
154
355
  found: results.length,
356
+ source: 'local',
155
357
  results: results.map(r => ({
156
358
  id: r.id,
157
359
  score: r.score ? Math.round(r.score * 1000) / 1000 : null,
@@ -165,29 +367,34 @@ async function handleSearchContext(args) {
165
367
  };
166
368
  }
167
369
 
168
- function handleGetEntry(args) {
370
+ async function handleGetEntry(args) {
169
371
  const { id } = args;
170
372
  const storePath = contentStore.resolveStorePath();
171
373
  const journalDir = contentStore.DEFAULT_JOURNAL_DIR;
172
374
 
173
- // Get entry metadata
375
+ // Try local first (fastest)
174
376
  const index = contentStore.getBackend(storePath).loadIndex();
175
- if (!index || !index.entries || !index.entries[id]) {
176
- return { error: `Entry not found: ${id}` };
377
+ if (index && index.entries && index.entries[id]) {
378
+ const entry = index.entries[id];
379
+ const fullContent = contentStore.getEntryContent(id, { storePath, journalDir });
380
+ return {
381
+ id,
382
+ date: entry.date,
383
+ repo: entry.repo,
384
+ title: entry.title,
385
+ source: entry.source,
386
+ tags: entry.tags || [],
387
+ content: fullContent || entry.summary || null,
388
+ };
177
389
  }
178
390
 
179
- const entry = index.entries[id];
180
- const fullContent = contentStore.getEntryContent(id, { storePath, journalDir });
391
+ // Not found locally — try container (may have entries from other team members)
392
+ const containerResult = await proxyGetEntry(id);
393
+ if (containerResult && !containerResult.error) {
394
+ return containerResult;
395
+ }
181
396
 
182
- return {
183
- id,
184
- date: entry.date,
185
- repo: entry.repo,
186
- title: entry.title,
187
- source: entry.source,
188
- tags: entry.tags || [],
189
- content: fullContent || entry.summary || null,
190
- };
397
+ return { error: `Entry not found: ${id}` };
191
398
  }
192
399
 
193
400
  function handleListRecent(args) {
@@ -382,7 +589,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
382
589
  let result;
383
590
  switch (name) {
384
591
  case 'search_context': result = await handleSearchContext(args); break;
385
- case 'get_entry': result = handleGetEntry(args); break;
592
+ case 'get_entry': result = await handleGetEntry(args); break;
386
593
  case 'list_recent': result = handleListRecent(args); break;
387
594
  case 'get_signals': result = handleGetSignals(args); break;
388
595
  case 'get_team_status': result = handleGetTeamStatus(args); break;
@@ -2103,7 +2103,7 @@ function commitAndPushTeamJournals(teamContextPath, copied) {
2103
2103
  stampMemberVersion(teamContextPath);
2104
2104
 
2105
2105
  try {
2106
- const gitAdd = spawnSync('git', ['add', 'journals/', 'members/'], { cwd: teamContextPath, stdio: 'pipe' });
2106
+ const gitAdd = spawnSync('git', ['add', 'journals/', 'members/', '.wayfind-api-key'], { cwd: teamContextPath, stdio: 'pipe' });
2107
2107
  if (gitAdd.status !== 0) {
2108
2108
  console.error(`git add failed: ${(gitAdd.stderr || '').toString().trim()}`);
2109
2109
  return;
@@ -3804,25 +3804,51 @@ async function runDeploy(args) {
3804
3804
  case 'status':
3805
3805
  deployStatus();
3806
3806
  break;
3807
+ case 'set-endpoint': {
3808
+ const endpointTeamId = teamId || (readContextConfig().default);
3809
+ const endpointUrl = filteredArgs[1];
3810
+ if (!endpointTeamId || !endpointUrl) {
3811
+ console.error('Usage: wayfind deploy set-endpoint <url> --team <id>');
3812
+ console.error('Example: wayfind deploy set-endpoint http://gregs-laptop:3141 --team abc123');
3813
+ process.exit(1);
3814
+ }
3815
+ const cfg = readContextConfig();
3816
+ if (!cfg.teams || !cfg.teams[endpointTeamId]) {
3817
+ console.error(`Team "${endpointTeamId}" not found in context.json`);
3818
+ process.exit(1);
3819
+ }
3820
+ cfg.teams[endpointTeamId].container_endpoint = endpointUrl;
3821
+ writeContextConfig(cfg);
3822
+ console.log(`Set container_endpoint for team "${endpointTeamId}" to ${endpointUrl}`);
3823
+ break;
3824
+ }
3807
3825
  default:
3808
3826
  console.error(`Unknown deploy subcommand: ${sub}`);
3809
- console.error('Available: init [--team <id>], list, status');
3827
+ console.error('Available: init [--team <id>], list, status, set-endpoint');
3810
3828
  process.exit(1);
3811
3829
  }
3812
3830
  }
3813
3831
 
3814
3832
  /**
3815
- * Scaffold a per-team container config at ~/.claude/team-context/teams/<teamId>/deploy/
3833
+ * Scaffold a per-team container config in the team's registered repo (deploy/ subdir).
3834
+ * Falls back to ~/.claude/team-context/teams/<teamId>/deploy/ if no repo is registered.
3816
3835
  */
3817
3836
  function deployTeamInit(teamId, { port } = {}) {
3818
- const teamsBaseDir = HOME ? path.join(HOME, '.claude', 'team-context', 'teams') : null;
3819
- if (!teamsBaseDir) {
3837
+ if (!HOME) {
3820
3838
  console.error('Cannot resolve home directory.');
3821
3839
  process.exit(1);
3822
3840
  }
3823
3841
 
3824
- const deployDir = path.join(teamsBaseDir, teamId, 'deploy');
3825
- const storeDir = path.join(teamsBaseDir, teamId, 'content-store');
3842
+ // Resolve deploy dir: team repo path first, fallback to store-adjacent
3843
+ const config = readContextConfig();
3844
+ const teamEntry = config.teams && config.teams[teamId];
3845
+ const teamContextPath = teamEntry ? teamEntry.path : null;
3846
+ const deployDir = teamContextPath
3847
+ ? path.join(teamContextPath, 'deploy')
3848
+ : path.join(HOME, '.claude', 'team-context', 'teams', teamId, 'deploy');
3849
+
3850
+ // Ensure the per-team store dir exists
3851
+ const storeDir = path.join(HOME, '.claude', 'team-context', 'teams', teamId, 'content-store');
3826
3852
 
3827
3853
  // Check for duplicate running container
3828
3854
  const psResult = spawnSync('docker', ['ps', '--filter', `label=com.wayfind.team=${teamId}`, '--format', '{{.Names}}'], { stdio: 'pipe' });
@@ -3838,22 +3864,42 @@ function deployTeamInit(teamId, { port } = {}) {
3838
3864
  console.log(`Scaffolding deploy config for team: ${teamId}`);
3839
3865
  console.log(`Deploy dir: ${deployDir}`);
3840
3866
 
3841
- // Resolve team-context repo path for volume mount
3842
- const config = readContextConfig();
3843
- const teamEntry = config.teams && config.teams[teamId];
3844
- const teamContextPath = teamEntry ? teamEntry.path : null;
3845
-
3846
- // Assign port (default 3141; if taken, user should pass --port)
3847
- const assignedPort = port || 3141;
3867
+ // Auto-detect port: find all wayfind containers, pick next available
3848
3868
  const containerName = `wayfind-${teamId}`;
3869
+ let assignedPort = port;
3870
+ if (!assignedPort) {
3871
+ const usedPorts = new Set();
3872
+ // Check labeled containers
3873
+ const portsResult = spawnSync('docker', [
3874
+ 'ps', '--filter', 'label=com.wayfind.team',
3875
+ '--format', '{{.Ports}}',
3876
+ ], { stdio: 'pipe' });
3877
+ // Also check legacy container named "wayfind"
3878
+ const legacyPortsResult = spawnSync('docker', [
3879
+ 'ps', '--filter', 'name=^wayfind',
3880
+ '--format', '{{.Ports}}',
3881
+ ], { stdio: 'pipe' });
3882
+ const allPortOutput = [
3883
+ (portsResult.stdout || '').toString(),
3884
+ (legacyPortsResult.stdout || '').toString(),
3885
+ ].join('\n');
3886
+ // Extract host ports from "0.0.0.0:3141->3141/tcp" patterns
3887
+ for (const match of allPortOutput.matchAll(/:(\d+)->/g)) {
3888
+ usedPorts.add(parseInt(match[1], 10));
3889
+ }
3890
+ assignedPort = 3141;
3891
+ while (usedPorts.has(assignedPort)) assignedPort++;
3892
+ if (assignedPort !== 3141) {
3893
+ console.log(`Port 3141 in use — assigning port ${assignedPort}`);
3894
+ }
3895
+ }
3849
3896
 
3850
3897
  // Build docker-compose.yml content with per-team overrides
3851
3898
  const templatePath = path.join(DEPLOY_TEMPLATES_DIR, 'docker-compose.yml');
3852
3899
  let composeContent = fs.readFileSync(templatePath, 'utf8');
3853
3900
  composeContent = composeContent
3854
3901
  .replace(/container_name: wayfind/, `container_name: ${containerName}`)
3855
- .replace(/- "3141:3141"/, `- "${assignedPort}:3141"`)
3856
- .replace(/(TEAM_CONTEXT_TENANT_ID:.*$)/m, `TEAM_CONTEXT_TENANT_ID: \${TEAM_CONTEXT_TENANT_ID:-${teamId}}`);
3902
+ .replace(/- "3141:3141"/, `- "${assignedPort}:3141"`);
3857
3903
 
3858
3904
  // Inject Docker label for discovery
3859
3905
  composeContent = composeContent.replace(
@@ -3869,42 +3915,72 @@ function deployTeamInit(teamId, { port } = {}) {
3869
3915
  console.log(' docker-compose.yml — already exists, skipping');
3870
3916
  }
3871
3917
 
3872
- // .env.example
3918
+ // .env.example — full reference with all options
3873
3919
  const envExampleSrc = path.join(DEPLOY_TEMPLATES_DIR, '.env.example');
3874
3920
  const envExampleDst = path.join(deployDir, '.env.example');
3875
3921
  if (!fs.existsSync(envExampleDst) && fs.existsSync(envExampleSrc)) {
3876
- let envContent = fs.readFileSync(envExampleSrc, 'utf8');
3877
- if (teamContextPath) {
3878
- envContent += `\nTEAM_CONTEXT_TEAM_CONTEXT_PATH=${teamContextPath}\n`;
3879
- }
3880
- fs.writeFileSync(envExampleDst, envContent, 'utf8');
3881
- console.log(' .env.example — created');
3922
+ fs.copyFileSync(envExampleSrc, envExampleDst);
3923
+ console.log(' .env.example — created (full reference)');
3882
3924
  }
3883
3925
 
3884
- // .env from .env.example
3926
+ // .env minimal seed with only required keys
3885
3927
  const envPath = path.join(deployDir, '.env');
3886
- if (!fs.existsSync(envPath) && fs.existsSync(envExampleDst)) {
3887
- fs.copyFileSync(envExampleDst, envPath);
3888
- console.log(' .env created from .env.example (fill in your tokens)');
3889
- }
3890
-
3891
- const ghToken = detectGitHubToken();
3892
- if (ghToken && fs.existsSync(envPath)) {
3893
- let envContent = fs.readFileSync(envPath, 'utf8');
3894
- if (!envContent.match(/^GITHUB_TOKEN=.+/m)) {
3895
- envContent = envContent.replace(/^GITHUB_TOKEN=.*$/m, `GITHUB_TOKEN=${ghToken}`);
3896
- if (!envContent.includes('GITHUB_TOKEN=')) envContent += `\nGITHUB_TOKEN=${ghToken}\n`;
3897
- fs.writeFileSync(envPath, envContent, 'utf8');
3928
+ if (!fs.existsSync(envPath)) {
3929
+ const ghToken = detectGitHubToken();
3930
+ const lines = [
3931
+ '# Wayfind — required configuration',
3932
+ '# See .env.example for all available options.',
3933
+ '',
3934
+ '# Anthropic API key (for digests and bot answers)',
3935
+ 'ANTHROPIC_API_KEY=sk-ant-your-key',
3936
+ '',
3937
+ '# GitHub token (for pulling team journals and signals)',
3938
+ `GITHUB_TOKEN=${ghToken || ''}`,
3939
+ '',
3940
+ `TEAM_CONTEXT_TENANT_ID=${teamId}`,
3941
+ ];
3942
+ // Set volume mount path so docker-compose.yml resolves correctly
3943
+ if (teamContextPath) {
3944
+ lines.push(`TEAM_CONTEXT_TEAM_CONTEXT_PATH=${teamContextPath}`);
3945
+ }
3946
+ fs.writeFileSync(envPath, lines.join('\n') + '\n', 'utf8');
3947
+ console.log(' .env — created (fill in ANTHROPIC_API_KEY)');
3948
+ if (ghToken) {
3898
3949
  console.log(' GITHUB_TOKEN — auto-detected from gh CLI');
3899
3950
  }
3951
+ } else {
3952
+ console.log(' .env — already exists, skipping');
3953
+ }
3954
+
3955
+ // Ensure deploy/.env is gitignored if we're in a repo
3956
+ if (teamContextPath) {
3957
+ const gitignorePath = path.join(teamContextPath, '.gitignore');
3958
+ const gitignoreEntry = 'deploy/.env';
3959
+ if (fs.existsSync(gitignorePath)) {
3960
+ const content = fs.readFileSync(gitignorePath, 'utf8');
3961
+ if (!content.includes(gitignoreEntry)) {
3962
+ fs.appendFileSync(gitignorePath, `\n${gitignoreEntry}\n`);
3963
+ console.log(' .gitignore — added deploy/.env');
3964
+ }
3965
+ }
3966
+ }
3967
+
3968
+ // Store container_endpoint in context.json so team members' MCP can discover it
3969
+ const updatedConfig = readContextConfig();
3970
+ if (updatedConfig.teams && updatedConfig.teams[teamId]) {
3971
+ updatedConfig.teams[teamId].container_endpoint = `http://localhost:${assignedPort}`;
3972
+ writeContextConfig(updatedConfig);
3973
+ console.log(` context.json — set container_endpoint to http://localhost:${assignedPort}`);
3974
+ console.log(' Tip: update the hostname if team members connect over a network (e.g. Tailscale).');
3900
3975
  }
3901
3976
 
3902
3977
  console.log('');
3903
3978
  console.log('Next steps:');
3904
- console.log(` 1. Fill in ${deployDir}/.env with your tokens`);
3979
+ console.log(` 1. Set ANTHROPIC_API_KEY in ${deployDir}/.env`);
3905
3980
  console.log(` 2. cd "${deployDir}" && docker compose up -d`);
3906
3981
  console.log(` 3. Verify: curl http://localhost:${assignedPort}/healthz`);
3907
3982
  console.log('');
3983
+ console.log('See .env.example for optional config (Slack, embeddings, signals, schedules).');
3908
3984
  console.log(`Tip: run "wayfind deploy list" to see all running team containers.`);
3909
3985
 
3910
3986
  telemetry.capture('deploy_team_init', { teamId }, CLI_USER);
@@ -3928,7 +4004,7 @@ function deployList() {
3928
4004
  const rows = (psResult.stdout || '').toString().trim();
3929
4005
  if (!rows) {
3930
4006
  console.log('No Wayfind team containers running.');
3931
- console.log('Start one with: wayfind deploy --team <teamId> && cd ~/.claude/team-context/teams/<teamId>/deploy && docker compose up -d');
4007
+ console.log('Start one with: wayfind deploy --team <teamId>');
3932
4008
  return;
3933
4009
  }
3934
4010
 
@@ -4125,14 +4201,149 @@ function deployStatus() {
4125
4201
  }
4126
4202
  }
4127
4203
 
4128
- // ── Health endpoint ──────────────────────────────────────────────────────────
4204
+ // ── API key management ──────────────────────────────────────────────────────
4205
+
4206
+ /**
4207
+ * Read or generate the API key for container search endpoints.
4208
+ * Key is stored in the team-context repo so team members can read it.
4209
+ */
4210
+ function getOrCreateApiKey() {
4211
+ const teamDir = process.env.TEAM_CONTEXT_TEAM_CONTEXT_DIR;
4212
+ if (!teamDir) return null;
4213
+
4214
+ const keyFile = path.join(teamDir, '.wayfind-api-key');
4215
+ try {
4216
+ if (fs.existsSync(keyFile)) {
4217
+ const key = fs.readFileSync(keyFile, 'utf8').trim();
4218
+ if (key.length >= 32) return key;
4219
+ }
4220
+ } catch (_) {}
4221
+
4222
+ // Generate a new key
4223
+ const key = crypto.randomBytes(32).toString('hex');
4224
+ try {
4225
+ fs.writeFileSync(keyFile, key + '\n', 'utf8');
4226
+ console.log(`[${new Date().toISOString()}] Generated new API key in ${keyFile}`);
4227
+ } catch (err) {
4228
+ console.error(`Failed to write API key: ${err.message}`);
4229
+ return null;
4230
+ }
4231
+ return key;
4232
+ }
4233
+
4234
+ /**
4235
+ * Rotate the API key and commit/push it to the team-context repo.
4236
+ */
4237
+ function rotateApiKey() {
4238
+ const teamDir = process.env.TEAM_CONTEXT_TEAM_CONTEXT_DIR;
4239
+ if (!teamDir) return;
4240
+
4241
+ const key = crypto.randomBytes(32).toString('hex');
4242
+ const keyFile = path.join(teamDir, '.wayfind-api-key');
4243
+ try {
4244
+ fs.writeFileSync(keyFile, key + '\n', 'utf8');
4245
+ currentApiKey = key;
4246
+ console.log(`[${new Date().toISOString()}] Rotated API key`);
4247
+ pushApiKey(teamDir);
4248
+ } catch (err) {
4249
+ console.error(`Key rotation failed: ${err.message}`);
4250
+ }
4251
+ }
4252
+
4253
+ /**
4254
+ * Git add/commit/push the API key file.
4255
+ */
4256
+ function pushApiKey(teamDir) {
4257
+ const token = process.env.GITHUB_TOKEN;
4258
+ const env = { ...process.env };
4259
+ const gitConfig = [['safe.directory', teamDir]];
4260
+ if (token) {
4261
+ env.GIT_ASKPASS = 'echo';
4262
+ env.GIT_TERMINAL_PROMPT = '0';
4263
+ gitConfig.push(['credential.helper', '']);
4264
+ gitConfig.push([`url.https://x-access-token:${token}@github.com/.insteadOf`, 'https://github.com/']);
4265
+ }
4266
+ env.GIT_CONFIG_COUNT = String(gitConfig.length);
4267
+ for (let i = 0; i < gitConfig.length; i++) {
4268
+ env[`GIT_CONFIG_KEY_${i}`] = gitConfig[i][0];
4269
+ env[`GIT_CONFIG_VALUE_${i}`] = gitConfig[i][1];
4270
+ }
4271
+
4272
+ try {
4273
+ spawnSync('git', ['add', '.wayfind-api-key'], { cwd: teamDir, env, stdio: 'pipe' });
4274
+ const diff = spawnSync('git', ['diff', '--cached', '--quiet'], { cwd: teamDir, env, stdio: 'pipe' });
4275
+ if (diff.status === 0) return; // Nothing to commit
4276
+ spawnSync('git', ['commit', '-m', 'Rotate Wayfind API key'], { cwd: teamDir, env, stdio: 'pipe' });
4277
+ const push = spawnSync('git', ['push'], { cwd: teamDir, env, stdio: 'pipe', timeout: 30000 });
4278
+ if (push.status !== 0) {
4279
+ // Rebase and retry on conflict
4280
+ spawnSync('git', ['pull', '--rebase'], { cwd: teamDir, env, stdio: 'pipe', timeout: 30000 });
4281
+ spawnSync('git', ['push'], { cwd: teamDir, env, stdio: 'pipe', timeout: 30000 });
4282
+ }
4283
+ } catch (err) {
4284
+ console.error(`API key push failed: ${err.message}`);
4285
+ }
4286
+ }
4287
+
4288
+ // Current in-memory API key (loaded on startup, updated on rotation)
4289
+ let currentApiKey = null;
4290
+
4291
+ // ── Health + API endpoint ───────────────────────────────────────────────────
4129
4292
 
4130
4293
  let healthStatus = { ok: true, mode: null, started: null, services: {} };
4131
4294
 
4295
+ /**
4296
+ * Parse JSON body from an incoming request.
4297
+ */
4298
+ function parseJsonBody(req) {
4299
+ return new Promise((resolve, reject) => {
4300
+ let body = '';
4301
+ req.on('data', (chunk) => { body += chunk; if (body.length > 1e6) reject(new Error('Body too large')); });
4302
+ req.on('end', () => {
4303
+ try { resolve(body ? JSON.parse(body) : {}); }
4304
+ catch (e) { reject(e); }
4305
+ });
4306
+ req.on('error', reject);
4307
+ });
4308
+ }
4309
+
4310
+ /**
4311
+ * Check Authorization header against the current API key.
4312
+ * Returns true if authorized, false otherwise (and sends 401).
4313
+ */
4314
+ function checkApiAuth(req, res) {
4315
+ if (!currentApiKey) {
4316
+ res.writeHead(503, { 'Content-Type': 'application/json' });
4317
+ res.end(JSON.stringify({ error: 'API key not configured' }));
4318
+ return false;
4319
+ }
4320
+ const auth = req.headers['authorization'] || '';
4321
+ const token = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
4322
+ if (token !== currentApiKey) {
4323
+ res.writeHead(401, { 'Content-Type': 'application/json' });
4324
+ res.end(JSON.stringify({ error: 'Invalid or expired API key' }));
4325
+ return false;
4326
+ }
4327
+ return true;
4328
+ }
4329
+
4132
4330
  function startHealthServer() {
4133
4331
  const port = parseInt(process.env.TEAM_CONTEXT_HEALTH_PORT || '3141', 10);
4134
- const server = http.createServer((req, res) => {
4135
- if (req.url === '/healthz' && req.method === 'GET') {
4332
+
4333
+ // Load API key for search endpoints
4334
+ const teamDir = process.env.TEAM_CONTEXT_TEAM_CONTEXT_DIR;
4335
+ const keyExisted = teamDir && fs.existsSync(path.join(teamDir, '.wayfind-api-key'));
4336
+ currentApiKey = getOrCreateApiKey();
4337
+ if (currentApiKey) {
4338
+ console.log('API search endpoints enabled (key loaded)');
4339
+ // Push newly generated key so team members can pull it
4340
+ if (!keyExisted && teamDir) pushApiKey(teamDir);
4341
+ }
4342
+
4343
+ const server = http.createServer(async (req, res) => {
4344
+ const url = new URL(req.url, `http://localhost:${port}`);
4345
+
4346
+ if (url.pathname === '/healthz' && req.method === 'GET') {
4136
4347
  // Enrich with index freshness
4137
4348
  const storePath = contentStore.resolveStorePath();
4138
4349
  const index = contentStore.loadIndex(storePath);
@@ -4147,17 +4358,108 @@ function startHealthServer() {
4147
4358
  const botExpected = healthStatus.services.bot === 'running';
4148
4359
  const slackHealthy = !botExpected || slackStatus.connected;
4149
4360
 
4150
- const response = { ...healthStatus, index: indexInfo, slack: slackStatus };
4361
+ const response = {
4362
+ ...healthStatus,
4363
+ index: indexInfo,
4364
+ slack: slackStatus,
4365
+ api: { enabled: !!currentApiKey },
4366
+ };
4151
4367
  const status = (healthStatus.ok && slackHealthy) ? 200 : 503;
4152
4368
  res.writeHead(status, { 'Content-Type': 'application/json' });
4153
4369
  res.end(JSON.stringify(response));
4154
- } else {
4155
- res.writeHead(404);
4156
- res.end();
4370
+ return;
4371
+ }
4372
+
4373
+ // ── Search API: POST /api/search ──
4374
+ if (url.pathname === '/api/search' && req.method === 'POST') {
4375
+ if (!checkApiAuth(req, res)) return;
4376
+ try {
4377
+ const body = await parseJsonBody(req);
4378
+ const { query, limit = 10, repo, since, mode } = body;
4379
+ if (!query) {
4380
+ res.writeHead(400, { 'Content-Type': 'application/json' });
4381
+ res.end(JSON.stringify({ error: 'query is required' }));
4382
+ return;
4383
+ }
4384
+
4385
+ const opts = { limit, repo, since };
4386
+ let results;
4387
+ if (mode === 'text') {
4388
+ results = contentStore.searchText(query, opts);
4389
+ } else {
4390
+ results = await contentStore.searchJournals(query, opts);
4391
+ }
4392
+
4393
+ const mapped = (results || []).map(r => ({
4394
+ id: r.id,
4395
+ score: r.score ? Math.round(r.score * 1000) / 1000 : null,
4396
+ date: r.entry.date,
4397
+ repo: r.entry.repo,
4398
+ title: r.entry.title,
4399
+ source: r.entry.source,
4400
+ tags: r.entry.tags || [],
4401
+ summary: r.entry.summary || null,
4402
+ }));
4403
+
4404
+ res.writeHead(200, { 'Content-Type': 'application/json' });
4405
+ res.end(JSON.stringify({ found: mapped.length, results: mapped }));
4406
+ } catch (err) {
4407
+ res.writeHead(500, { 'Content-Type': 'application/json' });
4408
+ res.end(JSON.stringify({ error: err.message }));
4409
+ }
4410
+ return;
4411
+ }
4412
+
4413
+ // ── Entry API: GET /api/entry/:id ──
4414
+ if (url.pathname.startsWith('/api/entry/') && req.method === 'GET') {
4415
+ if (!checkApiAuth(req, res)) return;
4416
+ try {
4417
+ const id = decodeURIComponent(url.pathname.slice('/api/entry/'.length));
4418
+ if (!id) {
4419
+ res.writeHead(400, { 'Content-Type': 'application/json' });
4420
+ res.end(JSON.stringify({ error: 'entry id is required' }));
4421
+ return;
4422
+ }
4423
+
4424
+ const storePath = contentStore.resolveStorePath();
4425
+ const journalDir = process.env.TEAM_CONTEXT_JOURNALS_DIR || contentStore.DEFAULT_JOURNAL_DIR;
4426
+ const index = contentStore.getBackend(storePath).loadIndex();
4427
+
4428
+ if (!index || !index.entries || !index.entries[id]) {
4429
+ res.writeHead(404, { 'Content-Type': 'application/json' });
4430
+ res.end(JSON.stringify({ error: `Entry not found: ${id}` }));
4431
+ return;
4432
+ }
4433
+
4434
+ const entry = index.entries[id];
4435
+ const fullContent = contentStore.getEntryContent(id, { storePath, journalDir });
4436
+
4437
+ res.writeHead(200, { 'Content-Type': 'application/json' });
4438
+ res.end(JSON.stringify({
4439
+ id,
4440
+ date: entry.date,
4441
+ repo: entry.repo,
4442
+ title: entry.title,
4443
+ source: entry.source,
4444
+ tags: entry.tags || [],
4445
+ content: fullContent || entry.summary || null,
4446
+ }));
4447
+ } catch (err) {
4448
+ res.writeHead(500, { 'Content-Type': 'application/json' });
4449
+ res.end(JSON.stringify({ error: err.message }));
4450
+ }
4451
+ return;
4157
4452
  }
4453
+
4454
+ res.writeHead(404);
4455
+ res.end();
4158
4456
  });
4159
4457
  server.listen(port, () => {
4160
- console.log(`Health endpoint: http://0.0.0.0:${port}/healthz`);
4458
+ console.log(`Health + API endpoint: http://0.0.0.0:${port}/healthz`);
4459
+ if (currentApiKey) {
4460
+ console.log(` Search API: POST http://0.0.0.0:${port}/api/search`);
4461
+ console.log(` Entry API: GET http://0.0.0.0:${port}/api/entry/:id`);
4462
+ }
4161
4463
  });
4162
4464
  return server;
4163
4465
  }
@@ -4170,8 +4472,10 @@ async function runStart() {
4170
4472
 
4171
4473
  // Validate required env vars before proceeding
4172
4474
  const missing = [];
4173
- if (!process.env.SLACK_BOT_TOKEN) missing.push('SLACK_BOT_TOKEN');
4174
- if (!process.env.SLACK_APP_TOKEN) missing.push('SLACK_APP_TOKEN');
4475
+ // Slack tokens are only required for modes that run the bot
4476
+ const needsSlack = ['bot', 'all-in-one'].includes(mode) && !process.env.TEAM_CONTEXT_NO_SLACK;
4477
+ if (needsSlack && !process.env.SLACK_BOT_TOKEN) missing.push('SLACK_BOT_TOKEN');
4478
+ if (needsSlack && !process.env.SLACK_APP_TOKEN) missing.push('SLACK_APP_TOKEN');
4175
4479
  if (!process.env.ANTHROPIC_API_KEY) missing.push('ANTHROPIC_API_KEY');
4176
4480
  if (missing.length > 0) {
4177
4481
  console.error('');
@@ -4181,6 +4485,8 @@ async function runStart() {
4181
4485
  console.error(' cp deploy/.env.example deploy/.env');
4182
4486
  console.error(' # Fill in your tokens, then: docker compose up -d');
4183
4487
  console.error('');
4488
+ console.error('Tip: set TEAM_CONTEXT_NO_SLACK=1 to run without Slack integration.');
4489
+ console.error('');
4184
4490
  process.exit(1);
4185
4491
  }
4186
4492
 
@@ -4309,6 +4615,14 @@ function runStartScheduler() {
4309
4615
  await indexSignalsIfAvailable();
4310
4616
  });
4311
4617
 
4618
+ // Rotate API key daily (default 2am)
4619
+ const keyRotateSchedule = process.env.TEAM_CONTEXT_KEY_ROTATE_SCHEDULE || '0 2 * * *';
4620
+ console.log(`API key rotation schedule: ${keyRotateSchedule}`);
4621
+ scheduleCron(keyRotateSchedule, () => {
4622
+ console.log(`[${new Date().toISOString()}] Rotating API key...`);
4623
+ rotateApiKey();
4624
+ });
4625
+
4312
4626
  console.log('Scheduler running. Waiting for scheduled events...');
4313
4627
  }
4314
4628
 
@@ -5181,13 +5495,13 @@ const COMMANDS = {
5181
5495
  if (labelDir && fs.existsSync(path.join(labelDir, 'docker-compose.yml'))) {
5182
5496
  composeDir = labelDir;
5183
5497
  } else {
5184
- // For per-team containers, check teams dir
5185
- const teamsBase = HOME ? path.join(HOME, '.claude', 'team-context', 'teams') : '';
5186
- if (teamsBase && fs.existsSync(teamsBase)) {
5187
- for (const tid of fs.readdirSync(teamsBase)) {
5188
- const candidate = path.join(teamsBase, tid, 'deploy');
5498
+ // Check team repo paths from context.json first
5499
+ const updateConfig = readContextConfig();
5500
+ if (updateConfig.teams) {
5501
+ for (const [tid, entry] of Object.entries(updateConfig.teams)) {
5502
+ if (!entry.path) continue;
5503
+ const candidate = path.join(entry.path, 'deploy');
5189
5504
  if (fs.existsSync(path.join(candidate, 'docker-compose.yml'))) {
5190
- // Check if this compose file manages this container
5191
5505
  const checkResult = spawnSync('docker', ['compose', 'ps', '--format', '{{.Name}}'], { cwd: candidate, stdio: 'pipe' });
5192
5506
  const composeContainers = (checkResult.stdout || '').toString();
5193
5507
  if (composeContainers.includes(containerName)) {
@@ -5197,6 +5511,23 @@ const COMMANDS = {
5197
5511
  }
5198
5512
  }
5199
5513
  }
5514
+ // Fallback: check store-adjacent deploy dirs
5515
+ if (!composeDir) {
5516
+ const teamsBase = HOME ? path.join(HOME, '.claude', 'team-context', 'teams') : '';
5517
+ if (teamsBase && fs.existsSync(teamsBase)) {
5518
+ for (const tid of fs.readdirSync(teamsBase)) {
5519
+ const candidate = path.join(teamsBase, tid, 'deploy');
5520
+ if (fs.existsSync(path.join(candidate, 'docker-compose.yml'))) {
5521
+ const checkResult = spawnSync('docker', ['compose', 'ps', '--format', '{{.Name}}'], { cwd: candidate, stdio: 'pipe' });
5522
+ const composeContainers = (checkResult.stdout || '').toString();
5523
+ if (composeContainers.includes(containerName)) {
5524
+ composeDir = candidate;
5525
+ break;
5526
+ }
5527
+ }
5528
+ }
5529
+ }
5530
+ }
5200
5531
  // Legacy fallback
5201
5532
  if (!composeDir) {
5202
5533
  const legacyCandidates = [process.cwd(), path.join(HOME || '', 'team-context', 'deploy')];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wayfind",
3
- "version": "2.0.44",
3
+ "version": "2.0.46",
4
4
  "description": "Team decision trail for AI-assisted development. The connective tissue between product, engineering, and strategy.",
5
5
  "bin": {
6
6
  "wayfind": "./bin/team-context.js",
@@ -1,14 +1,11 @@
1
1
  # Wayfind — Docker environment configuration
2
2
  # Copy to .env and fill in your values: cp .env.example .env
3
+ #
4
+ # Only ANTHROPIC_API_KEY and GITHUB_TOKEN are required.
5
+ # Everything else is optional — add as needed.
3
6
 
4
7
  # ── Required ──────────────────────────────────────────────────────────────────
5
8
 
6
- # Slack bot tokens — from the Slack app you created using slack-app-manifest.json.
7
- # Go to api.slack.com/apps → your Wayfind app → OAuth & Permissions for the bot token,
8
- # and Basic Information → App-Level Tokens for the app token.
9
- SLACK_BOT_TOKEN=xoxb-your-bot-token
10
- SLACK_APP_TOKEN=xapp-your-app-token
11
-
12
9
  # Anthropic API key (for digests and bot answers)
13
10
  # Get one at console.anthropic.com
14
11
  ANTHROPIC_API_KEY=sk-ant-your-key
@@ -18,73 +15,33 @@ ANTHROPIC_API_KEY=sk-ant-your-key
18
15
  # Needs read access to the team-context repo.
19
16
  GITHUB_TOKEN=
20
17
 
21
- # ── Digest delivery ───────────────────────────────────────────────────────────
22
- # Primary: bot token + channel. The bot posts digests via chat.postMessage,
23
- # which enables reaction tracking and threaded feedback.
24
- # Fallback: webhook. Used only if bot delivery fails.
18
+ # ── Slack (for digest delivery and bot) ──────────────────────────────────────
19
+ # Without these, set TEAM_CONTEXT_NO_SLACK=1 and the container runs in
20
+ # scheduler/worker-only mode (no digest posting, no bot answers).
25
21
 
26
- # Slack channel for digest delivery (channel ID, not name)
27
- # Right-click channel in Slack → View channel details → copy the ID at bottom
22
+ # SLACK_BOT_TOKEN=xoxb-your-bot-token
23
+ # SLACK_APP_TOKEN=xapp-your-app-token
28
24
  # SLACK_DIGEST_CHANNEL=C0123456789
29
-
30
- # Slack webhook — fallback for digest delivery if bot token is unavailable
31
25
  # TEAM_CONTEXT_SLACK_WEBHOOK=https://hooks.slack.com/services/T.../B.../...
32
26
 
33
- # ── Optional ──────────────────────────────────────────────────────────────────
34
-
35
- # Tenant identifier (prefixes storage paths)
36
- # TEAM_CONTEXT_TENANT_ID=my-team
37
-
38
- # Team repo allowlist — only journals from these repos appear in digests and queries.
39
- # Supports org/* wildcards. This is the recommended way to scope a container to one team.
40
- # TEAM_CONTEXT_INCLUDE_REPOS=MyOrg/*,MyOrg-Libs/*
41
-
42
- # DEPRECATED: Blocklist approach. Use INCLUDE_REPOS instead.
43
- # TEAM_CONTEXT_EXCLUDE_REPOS=wayfind,personal-project
44
-
45
- # Encryption key — generate with: openssl rand -base64 32
46
- # TEAM_CONTEXT_ENCRYPTION_KEY=
47
-
48
- # Author slug for CLI telemetry attribution
49
- # TEAM_CONTEXT_AUTHOR=greg
50
-
51
- # ── LLM models ───────────────────────────────────────────────────────────────
52
-
53
- # Model for digest generation and onboarding packs (default: claude-sonnet-4-5-20250929)
54
- # TEAM_CONTEXT_LLM_MODEL=claude-sonnet-4-5-20250929
55
-
56
- # Model for conversation transcript extraction (default: claude-sonnet-4-5-20250929)
57
- # TEAM_CONTEXT_EXTRACTION_MODEL=claude-sonnet-4-5-20250929
58
-
59
- # ── Embeddings (without these, bot uses keyword search only) ─────────────────
27
+ # ── Embeddings (for semantic search — falls back to keyword search without) ──
60
28
  # Option A: OpenAI
61
29
  # OPENAI_API_KEY=sk-your-openai-key
62
- # Option B: Azure OpenAI (if you have an AOAI deployment)
30
+ # Option B: Azure OpenAI
63
31
  # AZURE_OPENAI_EMBEDDING_ENDPOINT=https://your-resource.openai.azure.com/
64
32
  # AZURE_OPENAI_EMBEDDING_KEY=your-key
65
33
  # AZURE_OPENAI_EMBEDDING_DEPLOYMENT=text-embedding-3-small
66
34
 
67
- # ── Path overrides ───────────────────────────────────────────────────────────
68
-
69
- # Override content store path (default: ~/.claude/team-context/content-store)
70
- # TEAM_CONTEXT_STORE_PATH=/data/content-store
71
-
72
- # Override signals directory (default: ~/.claude/team-context/signals)
73
- # TEAM_CONTEXT_SIGNALS_DIR=/data/signals
35
+ # ── Team scoping ─────────────────────────────────────────────────────────────
74
36
 
75
- # Override conversations directory (default: ~/.claude/projects)
76
- # TEAM_CONTEXT_CONVERSATIONS_DIR=/data/conversations
77
-
78
- # ── Schedules ────────────────────────────────────────────────────────────────
79
-
80
- # Digest schedule (cron, default: daily 12pm UTC / 7am ET)
81
- # TEAM_CONTEXT_DIGEST_SCHEDULE=0 12 * * *
37
+ # Tenant identifier (prefixes storage paths)
38
+ # TEAM_CONTEXT_TENANT_ID=my-team
82
39
 
83
- # Signal pull schedule (cron, default: daily 6am UTC)
84
- # TEAM_CONTEXT_SIGNAL_SCHEDULE=0 6 * * *
40
+ # Team repo allowlist only journals from these repos appear in digests/queries.
41
+ # TEAM_CONTEXT_INCLUDE_REPOS=MyOrg/*,MyOrg-Libs/*
85
42
 
86
- # Reindex schedule (cron, default: hourly)
87
- # TEAM_CONTEXT_REINDEX_SCHEDULE=0 * * * *
43
+ # Author slug for telemetry attribution
44
+ # TEAM_CONTEXT_AUTHOR=greg
88
45
 
89
46
  # ── Signal sources ───────────────────────────────────────────────────────────
90
47
 
@@ -93,21 +50,28 @@ GITHUB_TOKEN=
93
50
 
94
51
  # Intercom API token for support signal ingestion
95
52
  # INTERCOM_TOKEN=your-intercom-token
96
-
97
- # Filter Intercom conversations by tags (comma-separated)
98
53
  # TEAM_CONTEXT_INTERCOM_TAGS=bug,feature-request
99
54
 
100
55
  # Notion integration token for page/database signal ingestion
101
- # Create at: https://www.notion.so/my-integrations
102
56
  # NOTION_TOKEN=ntn_your-integration-token
103
-
104
- # Specific Notion database IDs to monitor (comma-separated, or blank for all shared pages)
105
57
  # TEAM_CONTEXT_NOTION_DATABASES=db_id1,db_id2
106
58
 
107
- # ── Monitoring ───────────────────────────────────────────────────────────────
59
+ # ── Schedules ────────────────────────────────────────────────────────────────
108
60
 
109
- # Health check port (default: 3141)
110
- # TEAM_CONTEXT_HEALTH_PORT=3141
61
+ # TEAM_CONTEXT_DIGEST_SCHEDULE=0 12 * * *
62
+ # TEAM_CONTEXT_SIGNAL_SCHEDULE=0 6 * * *
63
+ # TEAM_CONTEXT_REINDEX_SCHEDULE=0 * * * *
111
64
 
112
- # Telemetry opt-in anonymous usage data to help improve Wayfind
65
+ # ── Advanced ─────────────────────────────────────────────────────────────────
66
+
67
+ # Set to 1 to run without Slack (scheduler + worker only, no bot)
68
+ # TEAM_CONTEXT_NO_SLACK=1
69
+
70
+ # TEAM_CONTEXT_LLM_MODEL=claude-sonnet-4-5-20250929
71
+ # TEAM_CONTEXT_EXTRACTION_MODEL=claude-sonnet-4-5-20250929
72
+ # TEAM_CONTEXT_ENCRYPTION_KEY=
73
+ # TEAM_CONTEXT_HEALTH_PORT=3141
113
74
  # TEAM_CONTEXT_TELEMETRY=true
75
+ # TEAM_CONTEXT_STORE_PATH=/data/content-store
76
+ # TEAM_CONTEXT_SIGNALS_DIR=/data/signals
77
+ # TEAM_CONTEXT_CONVERSATIONS_DIR=/data/conversations
@@ -7,52 +7,9 @@ services:
7
7
  image: ghcr.io/usewayfind/wayfind:latest
8
8
  container_name: wayfind
9
9
  restart: unless-stopped
10
+ env_file: .env
10
11
  environment:
11
12
  TEAM_CONTEXT_MODE: all-in-one
12
- TEAM_CONTEXT_TENANT_ID: ${TEAM_CONTEXT_TENANT_ID:-my-team}
13
- TEAM_CONTEXT_AUTHOR: ${TEAM_CONTEXT_AUTHOR:-}
14
-
15
- # Slack
16
- SLACK_BOT_TOKEN: ${SLACK_BOT_TOKEN}
17
- SLACK_APP_TOKEN: ${SLACK_APP_TOKEN}
18
- SLACK_DIGEST_CHANNEL: ${SLACK_DIGEST_CHANNEL:-}
19
- TEAM_CONTEXT_SLACK_WEBHOOK: ${TEAM_CONTEXT_SLACK_WEBHOOK:-}
20
-
21
- # Team repo allowlist (recommended over EXCLUDE_REPOS)
22
- TEAM_CONTEXT_INCLUDE_REPOS: ${TEAM_CONTEXT_INCLUDE_REPOS:-}
23
- # DEPRECATED: Blocklist approach — use INCLUDE_REPOS instead
24
- TEAM_CONTEXT_EXCLUDE_REPOS: ${TEAM_CONTEXT_EXCLUDE_REPOS:-}
25
-
26
- # Telemetry (opt-in, sends anonymous usage data to improve Wayfind)
27
- TEAM_CONTEXT_TELEMETRY: ${TEAM_CONTEXT_TELEMETRY:-false}
28
-
29
- # GitHub signals
30
- GITHUB_TOKEN: ${GITHUB_TOKEN:-}
31
-
32
- # Intercom signals
33
- INTERCOM_TOKEN: ${INTERCOM_TOKEN:-}
34
- TEAM_CONTEXT_INTERCOM_TAGS: ${TEAM_CONTEXT_INTERCOM_TAGS:-}
35
-
36
- # LLM
37
- ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
38
- TEAM_CONTEXT_LLM_MODEL: ${TEAM_CONTEXT_LLM_MODEL:-claude-sonnet-4-5-20250929}
39
-
40
- # Embeddings (for semantic search — falls back to keyword search without these)
41
- # Option A: OpenAI
42
- OPENAI_API_KEY: ${OPENAI_API_KEY:-}
43
- # Option B: Azure OpenAI
44
- AZURE_OPENAI_EMBEDDING_ENDPOINT: ${AZURE_OPENAI_EMBEDDING_ENDPOINT:-}
45
- AZURE_OPENAI_EMBEDDING_KEY: ${AZURE_OPENAI_EMBEDDING_KEY:-}
46
- AZURE_OPENAI_EMBEDDING_DEPLOYMENT: ${AZURE_OPENAI_EMBEDDING_DEPLOYMENT:-text-embedding-3-small}
47
-
48
- # Encryption — generate with: openssl rand -base64 32
49
- TEAM_CONTEXT_ENCRYPTION_KEY: ${TEAM_CONTEXT_ENCRYPTION_KEY:-}
50
-
51
- # Scheduling
52
- TEAM_CONTEXT_DIGEST_SCHEDULE: ${TEAM_CONTEXT_DIGEST_SCHEDULE:-0 8 * * 1}
53
- TEAM_CONTEXT_SIGNAL_SCHEDULE: ${TEAM_CONTEXT_SIGNAL_SCHEDULE:-0 6 * * *}
54
-
55
- # Team context repo (mounted at /data/team-context for git pull)
56
13
  TEAM_CONTEXT_TEAM_CONTEXT_DIR: /data/team-context
57
14
  TEAM_CONTEXT_JOURNALS_DIR: /data/team-context/journals
58
15
  volumes:
@@ -49,7 +49,7 @@ Record how you actually think about recurring decision types. Examples:
49
49
 
50
50
  ## Memory Files (load on demand)
51
51
 
52
- Load these from `~/.ai-memory/memory/` when the session topic matches:
52
+ Load these from `~/.claude/memory/` when the session topic matches:
53
53
 
54
54
  | File | When to load | Summary |
55
55
  |------|-------------|---------|
@@ -59,21 +59,21 @@ Load these from `~/.ai-memory/memory/` when the session topic matches:
59
59
 
60
60
  | Location | Covers |
61
61
  |----------|--------|
62
- | `~/.ai-memory/state.md` | Admin work, non-repo tasks |
63
- | `~/repos/org/repo/.ai-memory/state.md` | What this repo is about |
62
+ | `~/.claude/state.md` | Admin work, non-repo tasks |
63
+ | `~/repos/org/repo/.claude/state.md` | What this repo is about |
64
64
 
65
65
  ## Session Protocol
66
66
 
67
67
  **Start:**
68
- 1. Read this file + the repo's `.ai-memory/state.md`
68
+ 1. Read this file + the repo's `.claude/state.md`
69
69
  2. Check Memory Files table — load any that match this session's topic
70
70
  3. Summarize current state, then ask: **"What's the goal for this session? What does success look like?"**
71
71
 
72
72
  **Mid-session drift check:** If work diverges from the stated goal, flag it gently and ask whether to stay the course or pivot.
73
73
 
74
74
  **End (on "stop" / "done" / "pause" / "tomorrow"):**
75
- 1. Update the repo's `.ai-memory/state.md`
75
+ 1. Update the repo's `.claude/state.md`
76
76
  2. Do NOT update this file's Active Projects table — it is rebuilt automatically by `wayfind status`.
77
77
  3. Create/update topic memory files for any significant new cross-repo context
78
- 4. Append to `~/.ai-memory/memory/journal/YYYY-MM-DD.md`
78
+ 4. Append to `~/.claude/memory/journal/YYYY-MM-DD.md`
79
79
  5. Confirm: **"State saved. Say 'let's continue' next time."**