wayfind 2.0.38 → 2.0.40

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.
@@ -17,6 +17,98 @@ const DEFAULT_SIGNALS_DIR = HOME ? path.join(HOME, '.claude', 'team-context', 's
17
17
  const INDEX_VERSION = '2.0.0';
18
18
  const FILE_PERMS = 0o600;
19
19
 
20
+ // ── Team-scoped path resolution ──────────────────────────────────────────────
21
+
22
+ /** Cache team ID for the process lifetime (cwd doesn't change mid-command). */
23
+ let _cachedTeamId;
24
+
25
+ /**
26
+ * Resolve the active team ID from the local repo binding or context.json default.
27
+ * @returns {string|null}
28
+ */
29
+ function _resolveTeamId() {
30
+ if (_cachedTeamId !== undefined) return _cachedTeamId;
31
+
32
+ // 1. .claude/wayfind.json in cwd
33
+ try {
34
+ const bindingFile = require('path').join(process.cwd(), '.claude', 'wayfind.json');
35
+ if (require('fs').existsSync(bindingFile)) {
36
+ const binding = JSON.parse(require('fs').readFileSync(bindingFile, 'utf8'));
37
+ if (binding.team_id) { _cachedTeamId = binding.team_id; return _cachedTeamId; }
38
+ }
39
+ } catch (_) {}
40
+
41
+ // 2. context.json default team
42
+ try {
43
+ const contextFile = HOME ? require('path').join(HOME, '.claude', 'team-context', 'context.json') : null;
44
+ if (contextFile && require('fs').existsSync(contextFile)) {
45
+ const ctx = JSON.parse(require('fs').readFileSync(contextFile, 'utf8'));
46
+ if (ctx.default) { _cachedTeamId = ctx.default; return _cachedTeamId; }
47
+ }
48
+ } catch (_) {}
49
+
50
+ _cachedTeamId = null;
51
+ return null;
52
+ }
53
+
54
+ /**
55
+ * One-time migration: copy global content store to the team-scoped path.
56
+ * Safe to call repeatedly — exits immediately if team store already exists.
57
+ */
58
+ function _autoMigrateToTeamStore(teamStorePath) {
59
+ if (fs.existsSync(teamStorePath)) return;
60
+ if (!DEFAULT_STORE_PATH || !fs.existsSync(DEFAULT_STORE_PATH)) return;
61
+ try {
62
+ fs.mkdirSync(teamStorePath, { recursive: true });
63
+ for (const f of fs.readdirSync(DEFAULT_STORE_PATH)) {
64
+ const src = path.join(DEFAULT_STORE_PATH, f);
65
+ if (fs.statSync(src).isFile()) {
66
+ fs.copyFileSync(src, path.join(teamStorePath, f));
67
+ }
68
+ }
69
+ fs.writeFileSync(path.join(teamStorePath, '.migrated-from-global'), new Date().toISOString());
70
+ } catch (_) {
71
+ // Non-fatal — fresh store will be created on first write
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Resolve the content store path.
77
+ * Priority:
78
+ * 1. TEAM_CONTEXT_STORE_PATH env var (container always sets this)
79
+ * 2. Explicit teamId argument
80
+ * 3. Repo-level .claude/wayfind.json team_id
81
+ * 4. context.json default team
82
+ * 5. Legacy DEFAULT_STORE_PATH
83
+ * @param {string} [teamId] - Explicit team ID override
84
+ * @returns {string|null}
85
+ */
86
+ function resolveStorePath(teamId) {
87
+ if (process.env.TEAM_CONTEXT_STORE_PATH) return process.env.TEAM_CONTEXT_STORE_PATH;
88
+ const tid = teamId || _resolveTeamId();
89
+ if (tid && HOME) {
90
+ const teamStorePath = path.join(HOME, '.claude', 'team-context', 'teams', tid, 'content-store');
91
+ _autoMigrateToTeamStore(teamStorePath);
92
+ return teamStorePath;
93
+ }
94
+ return DEFAULT_STORE_PATH;
95
+ }
96
+
97
+ /**
98
+ * Resolve the signals directory path.
99
+ * Same priority chain as resolveStorePath.
100
+ * @param {string} [teamId] - Explicit team ID override
101
+ * @returns {string|null}
102
+ */
103
+ function resolveSignalsDir(teamId) {
104
+ if (process.env.TEAM_CONTEXT_SIGNALS_DIR) return process.env.TEAM_CONTEXT_SIGNALS_DIR;
105
+ const tid = teamId || _resolveTeamId();
106
+ if (tid && HOME) {
107
+ return path.join(HOME, '.claude', 'team-context', 'teams', tid, 'signals');
108
+ }
109
+ return DEFAULT_SIGNALS_DIR;
110
+ }
111
+
20
112
  // Field mapping for journal entries
21
113
  const FIELD_MAP = {
22
114
  'Why': 'why',
@@ -310,7 +402,7 @@ function cosineSimilarity(a, b) {
310
402
  */
311
403
  async function indexJournals(options = {}) {
312
404
  const journalDir = options.journalDir || DEFAULT_JOURNAL_DIR;
313
- const storePath = options.storePath || DEFAULT_STORE_PATH;
405
+ const storePath = options.storePath || resolveStorePath();
314
406
  const doEmbeddings = options.embeddings !== undefined
315
407
  ? options.embeddings
316
408
  : !!(process.env.OPENAI_API_KEY || process.env.AZURE_OPENAI_EMBEDDING_ENDPOINT || llm.isSimulation());
@@ -468,7 +560,7 @@ async function indexJournals(options = {}) {
468
560
  * @returns {Promise<Array<{ id: string, score: number, entry: Object }>>}
469
561
  */
470
562
  async function searchJournals(query, options = {}) {
471
- const storePath = options.storePath || DEFAULT_STORE_PATH;
563
+ const storePath = options.storePath || resolveStorePath();
472
564
  const limit = options.limit || 10;
473
565
 
474
566
  const backend = getBackend(storePath);
@@ -522,7 +614,7 @@ async function searchJournals(query, options = {}) {
522
614
  * @returns {Array<{ id: string, score: number, entry: Object }>}
523
615
  */
524
616
  function searchText(query, options = {}) {
525
- const storePath = options.storePath || DEFAULT_STORE_PATH;
617
+ const storePath = options.storePath || resolveStorePath();
526
618
  const journalDir = options.journalDir || DEFAULT_JOURNAL_DIR;
527
619
  const limit = options.limit || 10;
528
620
 
@@ -571,7 +663,7 @@ function searchText(query, options = {}) {
571
663
 
572
664
  // For signal entries, read content directly from the signal file
573
665
  if (entry.source === 'signal') {
574
- const signalsDir = options.signalsDir || DEFAULT_SIGNALS_DIR;
666
+ const signalsDir = options.signalsDir || resolveSignalsDir();
575
667
  if (signalsDir) {
576
668
  // Signal files live at signalsDir/<channel>/<date>.md or signalsDir/<channel>/<owner>/<repo>/<date>.md
577
669
  // The repo field tells us the path: "signals/<channel>" or "<owner>/<repo>"
@@ -677,7 +769,7 @@ function applyFilters(entry, filters) {
677
769
  * @returns {Array<{ id: string, entry: Object }>}
678
770
  */
679
771
  function queryMetadata(options = {}) {
680
- const storePath = options.storePath || DEFAULT_STORE_PATH;
772
+ const storePath = options.storePath || resolveStorePath();
681
773
  const index = getBackend(storePath).loadIndex();
682
774
  if (!index) return [];
683
775
 
@@ -699,7 +791,7 @@ function queryMetadata(options = {}) {
699
791
  * @returns {Object} - Insights object
700
792
  */
701
793
  function extractInsights(options = {}) {
702
- const storePath = options.storePath || DEFAULT_STORE_PATH;
794
+ const storePath = options.storePath || resolveStorePath();
703
795
  const index = getBackend(storePath).loadIndex();
704
796
  if (!index || index.entryCount === 0) {
705
797
  return {
@@ -781,9 +873,9 @@ function extractInsights(options = {}) {
781
873
  * @returns {string|null} - Full entry text, or null if not found
782
874
  */
783
875
  function getEntryContent(entryId, options = {}) {
784
- const storePath = options.storePath || DEFAULT_STORE_PATH;
876
+ const storePath = options.storePath || resolveStorePath();
785
877
  const journalDir = options.journalDir || DEFAULT_JOURNAL_DIR;
786
- const signalsDir = options.signalsDir || DEFAULT_SIGNALS_DIR;
878
+ const signalsDir = options.signalsDir || resolveSignalsDir();
787
879
 
788
880
  const index = getBackend(storePath).loadIndex();
789
881
  if (!index || !index.entries[entryId]) return null;
@@ -1258,7 +1350,7 @@ function fileFingerprint(filePath) {
1258
1350
  */
1259
1351
  async function indexConversations(options = {}) {
1260
1352
  const projectsDir = options.projectsDir || DEFAULT_PROJECTS_DIR;
1261
- const storePath = options.storePath || DEFAULT_STORE_PATH;
1353
+ const storePath = options.storePath || resolveStorePath();
1262
1354
  const doEmbeddings = options.embeddings !== undefined
1263
1355
  ? options.embeddings
1264
1356
  : !!(process.env.OPENAI_API_KEY || process.env.AZURE_OPENAI_EMBEDDING_ENDPOINT || llm.isSimulation());
@@ -1505,7 +1597,7 @@ Rules:
1505
1597
  * @returns {Promise<string>} - Synthesized onboarding document in markdown
1506
1598
  */
1507
1599
  async function generateOnboardingPack(repoQuery, options = {}) {
1508
- const storePath = options.storePath || DEFAULT_STORE_PATH;
1600
+ const storePath = options.storePath || resolveStorePath();
1509
1601
  const journalDir = options.journalDir || DEFAULT_JOURNAL_DIR;
1510
1602
  const days = options.days || 90;
1511
1603
  const llmConfig = options.llmConfig || {
@@ -1683,8 +1775,8 @@ async function indexConversationsWithExport(options = {}) {
1683
1775
  * @returns {Promise<Object>} - Stats: { fileCount, newEntries, updatedEntries, skippedEntries }
1684
1776
  */
1685
1777
  async function indexSignals(options = {}) {
1686
- const signalsDir = options.signalsDir || DEFAULT_SIGNALS_DIR;
1687
- const storePath = options.storePath || DEFAULT_STORE_PATH;
1778
+ const signalsDir = options.signalsDir || resolveSignalsDir();
1779
+ const storePath = options.storePath || resolveStorePath();
1688
1780
  const doEmbeddings = options.embeddings !== undefined
1689
1781
  ? options.embeddings
1690
1782
  : !!(process.env.OPENAI_API_KEY || process.env.AZURE_OPENAI_EMBEDDING_ENDPOINT || llm.isSimulation());
@@ -1973,11 +2065,11 @@ async function indexSignals(options = {}) {
1973
2065
  // ── Digest feedback ─────────────────────────────────────────────────────────
1974
2066
 
1975
2067
  function loadFeedback(storePath) {
1976
- return getBackend(storePath || DEFAULT_STORE_PATH).loadFeedback();
2068
+ return getBackend(storePath || resolveStorePath()).loadFeedback();
1977
2069
  }
1978
2070
 
1979
2071
  function saveFeedback(storePath, feedback) {
1980
- getBackend(storePath || DEFAULT_STORE_PATH).saveFeedback(feedback);
2072
+ getBackend(storePath || resolveStorePath()).saveFeedback(feedback);
1981
2073
  }
1982
2074
 
1983
2075
  /**
@@ -2082,7 +2174,7 @@ function getDigestFeedback(options = {}) {
2082
2174
  * focus: string[] }}
2083
2175
  */
2084
2176
  function computeQualityProfile(options = {}) {
2085
- const storePath = options.storePath || DEFAULT_STORE_PATH;
2177
+ const storePath = options.storePath || resolveStorePath();
2086
2178
  const days = options.days || 30;
2087
2179
  const index = getBackend(storePath).loadIndex();
2088
2180
 
@@ -2217,12 +2309,12 @@ module.exports = {
2217
2309
  getBackend,
2218
2310
  getBackendType,
2219
2311
  // Backward-compatible storage wrappers (delegate to backend)
2220
- loadIndex: (storePath) => getBackend(storePath || DEFAULT_STORE_PATH).loadIndex(),
2221
- saveIndex: (storePath, index) => getBackend(storePath || DEFAULT_STORE_PATH).saveIndex(index),
2222
- loadEmbeddings: (storePath) => getBackend(storePath || DEFAULT_STORE_PATH).loadEmbeddings(),
2223
- saveEmbeddings: (storePath, embeddings) => getBackend(storePath || DEFAULT_STORE_PATH).saveEmbeddings(embeddings),
2224
- loadConversationIndex: (storePath) => getBackend(storePath || DEFAULT_STORE_PATH).loadConversationIndex(),
2225
- saveConversationIndex: (storePath, convIndex) => getBackend(storePath || DEFAULT_STORE_PATH).saveConversationIndex(convIndex),
2312
+ loadIndex: (storePath) => getBackend(storePath || resolveStorePath()).loadIndex(),
2313
+ saveIndex: (storePath, index) => getBackend(storePath || resolveStorePath()).saveIndex(index),
2314
+ loadEmbeddings: (storePath) => getBackend(storePath || resolveStorePath()).loadEmbeddings(),
2315
+ saveEmbeddings: (storePath, embeddings) => getBackend(storePath || resolveStorePath()).saveEmbeddings(embeddings),
2316
+ loadConversationIndex: (storePath) => getBackend(storePath || resolveStorePath()).loadConversationIndex(),
2317
+ saveConversationIndex: (storePath, convIndex) => getBackend(storePath || resolveStorePath()).saveConversationIndex(convIndex),
2226
2318
 
2227
2319
  // Filtering
2228
2320
  isRepoExcluded,
@@ -2256,6 +2348,8 @@ module.exports = {
2256
2348
  DEFAULT_JOURNAL_DIR,
2257
2349
  DEFAULT_PROJECTS_DIR,
2258
2350
  DEFAULT_SIGNALS_DIR,
2351
+ resolveStorePath,
2352
+ resolveSignalsDir,
2259
2353
 
2260
2354
  // Digest feedback
2261
2355
  loadFeedback,
@@ -0,0 +1,411 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Wayfind MCP Server — stdio transport.
6
+ *
7
+ * Exposes team context (journals, signals, decisions) as MCP tools so any
8
+ * MCP-compatible AI tool can query the same knowledge base that Wayfind builds.
9
+ * Team-scoped from day one: resolveStorePath() picks the right store for the
10
+ * active team based on the working directory.
11
+ *
12
+ * Usage (Claude Code):
13
+ * Add to ~/.claude/claude_desktop_config.json:
14
+ * {
15
+ * "mcpServers": {
16
+ * "wayfind": { "command": "wayfind-mcp" }
17
+ * }
18
+ * }
19
+ *
20
+ * Or run directly: wayfind-mcp
21
+ */
22
+
23
+ const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
24
+ const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
25
+ const {
26
+ CallToolRequestSchema,
27
+ ListToolsRequestSchema,
28
+ } = require('@modelcontextprotocol/sdk/types.js');
29
+ const path = require('path');
30
+ const fs = require('fs');
31
+ const contentStore = require('./content-store.js');
32
+
33
+ const pkg = require('../package.json');
34
+
35
+ // ── Tool definitions ─────────────────────────────────────────────────────────
36
+
37
+ const TOOLS = [
38
+ {
39
+ name: 'search_context',
40
+ description: 'Search team context by natural language or keyword. Returns ranked journal entries, decisions, and signals. Use this to answer questions about past work, architectural decisions, and team activity.',
41
+ inputSchema: {
42
+ type: 'object',
43
+ properties: {
44
+ query: { type: 'string', description: 'Natural language search query' },
45
+ limit: { type: 'number', description: 'Max results (default: 10)' },
46
+ repo: { type: 'string', description: 'Filter by repository name (e.g. "MyService", "MyOrg/my-repo")' },
47
+ since: { type: 'string', description: 'Filter to entries on or after this date (YYYY-MM-DD)' },
48
+ mode: { type: 'string', enum: ['semantic', 'text'], description: 'Search mode — semantic uses embeddings, text uses keyword matching. Defaults to semantic if embeddings available.' },
49
+ },
50
+ required: ['query'],
51
+ },
52
+ },
53
+ {
54
+ name: 'get_entry',
55
+ description: 'Retrieve the full content of a specific journal or signal entry by ID. Use the IDs returned by search_context or list_recent.',
56
+ inputSchema: {
57
+ type: 'object',
58
+ properties: {
59
+ id: { type: 'string', description: 'Entry ID from search_context or list_recent results' },
60
+ },
61
+ required: ['id'],
62
+ },
63
+ },
64
+ {
65
+ name: 'list_recent',
66
+ description: 'List recent journal entries and decisions, optionally filtered by repo or date range. Returns metadata (no full content — use get_entry for that).',
67
+ inputSchema: {
68
+ type: 'object',
69
+ properties: {
70
+ limit: { type: 'number', description: 'Max entries to return (default: 20)' },
71
+ repo: { type: 'string', description: 'Filter by repository name' },
72
+ since: { type: 'string', description: 'Filter to entries on or after this date (YYYY-MM-DD)' },
73
+ source: { type: 'string', enum: ['journal', 'conversation', 'signal'], description: 'Filter by entry source type' },
74
+ },
75
+ },
76
+ },
77
+ {
78
+ name: 'get_signals',
79
+ description: 'Retrieve recent signal entries (GitHub activity, Slack summaries, Intercom updates, Notion pages) for a specific channel or all channels.',
80
+ inputSchema: {
81
+ type: 'object',
82
+ properties: {
83
+ channel: { type: 'string', description: 'Signal channel name (e.g. "github", "slack", "intercom"). Omit for all channels.' },
84
+ since: { type: 'string', description: 'Filter to signals on or after this date (YYYY-MM-DD). Defaults to last 7 days.' },
85
+ limit: { type: 'number', description: 'Max signals to return (default: 20)' },
86
+ },
87
+ },
88
+ },
89
+ {
90
+ name: 'get_team_status',
91
+ description: 'Get the current team state: who is working on what, active projects, recent decisions, and open blockers. Reads from team-state.md and personal-state.md files.',
92
+ inputSchema: {
93
+ type: 'object',
94
+ properties: {
95
+ include_personal: { type: 'boolean', description: 'Include personal-state.md in addition to team-state.md (default: true)' },
96
+ },
97
+ },
98
+ },
99
+ {
100
+ name: 'get_personas',
101
+ description: 'List the configured Wayfind personas (e.g. Greg/engineering, Sean/strategy). Each persona gets a tailored digest. Useful for understanding who uses this team context and how.',
102
+ inputSchema: {
103
+ type: 'object',
104
+ properties: {},
105
+ },
106
+ },
107
+ {
108
+ name: 'record_feedback',
109
+ description: 'Record that a context result was helpful or not. This improves future retrieval quality by down-weighting unhelpful entries.',
110
+ inputSchema: {
111
+ type: 'object',
112
+ properties: {
113
+ entry_id: { type: 'string', description: 'ID of the entry being rated' },
114
+ helpful: { type: 'boolean', description: 'Was this entry useful for the task?' },
115
+ query: { type: 'string', description: 'The original query that surfaced this entry (for context)' },
116
+ },
117
+ required: ['entry_id', 'helpful'],
118
+ },
119
+ },
120
+ {
121
+ name: 'add_context',
122
+ description: 'Add a new context entry to the team knowledge base. Use this to capture decisions, blockers, or key context from an AI session that should be available to future sessions.',
123
+ inputSchema: {
124
+ type: 'object',
125
+ properties: {
126
+ title: { type: 'string', description: 'Short title for the entry (< 80 chars)' },
127
+ content: { type: 'string', description: 'Full content in markdown. Should include Why, What, and any key decisions.' },
128
+ repo: { type: 'string', description: 'Repository this entry belongs to (e.g. "MyOrg/my-repo")' },
129
+ tags: { type: 'array', items: { type: 'string' }, description: 'Tags for categorization' },
130
+ },
131
+ required: ['title', 'content'],
132
+ },
133
+ },
134
+ ];
135
+
136
+ // ── Tool handlers ────────────────────────────────────────────────────────────
137
+
138
+ async function handleSearchContext(args) {
139
+ const { query, limit = 10, repo, since, mode } = args;
140
+ const opts = { limit, repo, since };
141
+
142
+ let results;
143
+ if (mode === 'text') {
144
+ results = contentStore.searchText(query, opts);
145
+ } else {
146
+ results = await contentStore.searchJournals(query, opts);
147
+ }
148
+
149
+ if (!results || results.length === 0) {
150
+ return { found: 0, results: [], hint: 'No matches. Try a broader query or check wayfind reindex.' };
151
+ }
152
+
153
+ return {
154
+ found: results.length,
155
+ results: results.map(r => ({
156
+ id: r.id,
157
+ score: r.score ? Math.round(r.score * 1000) / 1000 : null,
158
+ date: r.entry.date,
159
+ repo: r.entry.repo,
160
+ title: r.entry.title,
161
+ source: r.entry.source,
162
+ tags: r.entry.tags || [],
163
+ summary: r.entry.summary || null,
164
+ })),
165
+ };
166
+ }
167
+
168
+ function handleGetEntry(args) {
169
+ const { id } = args;
170
+ const storePath = contentStore.resolveStorePath();
171
+ const journalDir = contentStore.DEFAULT_JOURNAL_DIR;
172
+
173
+ // Get entry metadata
174
+ const index = contentStore.getBackend(storePath).loadIndex();
175
+ if (!index || !index.entries || !index.entries[id]) {
176
+ return { error: `Entry not found: ${id}` };
177
+ }
178
+
179
+ const entry = index.entries[id];
180
+ const fullContent = contentStore.getEntryContent(id, { storePath, journalDir });
181
+
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
+ };
191
+ }
192
+
193
+ function handleListRecent(args) {
194
+ const { limit = 20, repo, since, source } = args;
195
+ const opts = { limit, repo, since, source };
196
+ const results = contentStore.queryMetadata(opts);
197
+ const top = results.slice(0, limit);
198
+
199
+ return {
200
+ total: results.length,
201
+ showing: top.length,
202
+ entries: top.map(r => ({
203
+ id: r.id,
204
+ date: r.entry.date,
205
+ repo: r.entry.repo,
206
+ title: r.entry.title,
207
+ source: r.entry.source,
208
+ tags: r.entry.tags || [],
209
+ })),
210
+ };
211
+ }
212
+
213
+ function handleGetSignals(args) {
214
+ const { channel, limit = 20 } = args;
215
+ const signalsDir = contentStore.resolveSignalsDir();
216
+ const sinceDate = args.since || (() => {
217
+ const d = new Date();
218
+ d.setDate(d.getDate() - 7);
219
+ return d.toISOString().slice(0, 10);
220
+ })();
221
+
222
+ if (!signalsDir || !fs.existsSync(signalsDir)) {
223
+ return { error: 'No signals directory found. Run "wayfind pull --all" first.', signals: [] };
224
+ }
225
+
226
+ const channels = channel
227
+ ? [channel]
228
+ : fs.readdirSync(signalsDir).filter(d => {
229
+ try { return fs.statSync(path.join(signalsDir, d)).isDirectory(); } catch { return false; }
230
+ });
231
+
232
+ const signals = [];
233
+ for (const ch of channels) {
234
+ const chDir = path.join(signalsDir, ch);
235
+ if (!fs.existsSync(chDir)) continue;
236
+
237
+ const files = fs.readdirSync(chDir)
238
+ .filter(f => f.endsWith('.md') && f >= sinceDate)
239
+ .sort()
240
+ .reverse()
241
+ .slice(0, limit);
242
+
243
+ for (const f of files) {
244
+ const filePath = path.join(chDir, f);
245
+ try {
246
+ const content = fs.readFileSync(filePath, 'utf8');
247
+ signals.push({
248
+ channel: ch,
249
+ date: f.slice(0, 10),
250
+ file: f,
251
+ content: content.slice(0, 2000) + (content.length > 2000 ? '\n...(truncated)' : ''),
252
+ });
253
+ } catch (_) {}
254
+ }
255
+ }
256
+
257
+ signals.sort((a, b) => b.date.localeCompare(a.date));
258
+ return { signals: signals.slice(0, limit) };
259
+ }
260
+
261
+ function handleGetTeamStatus(args) {
262
+ const { include_personal = true } = args;
263
+ const HOME = process.env.HOME || process.env.USERPROFILE;
264
+ const wayfindDir = process.env.WAYFIND_DIR || (HOME ? path.join(HOME, '.claude', 'team-context') : null);
265
+
266
+ const result = {};
267
+
268
+ // Try to read team-state.md from cwd's .claude/ directory
269
+ const cwdTeamState = path.join(process.cwd(), '.claude', 'team-state.md');
270
+ const cwdPersonalState = path.join(process.cwd(), '.claude', 'personal-state.md');
271
+
272
+ for (const [key, filePath] of [['team_state', cwdTeamState], ['personal_state', cwdPersonalState]]) {
273
+ if (key === 'personal_state' && !include_personal) continue;
274
+ try {
275
+ if (fs.existsSync(filePath)) {
276
+ result[key] = fs.readFileSync(filePath, 'utf8');
277
+ }
278
+ } catch (_) {}
279
+ }
280
+
281
+ if (Object.keys(result).length === 0) {
282
+ return { error: 'No state files found in .claude/. Make sure Wayfind is initialized in this repo.' };
283
+ }
284
+
285
+ return result;
286
+ }
287
+
288
+ function handleGetPersonas() {
289
+ const HOME = process.env.HOME || process.env.USERPROFILE;
290
+ const connectorsFile = HOME ? path.join(HOME, '.claude', 'team-context', 'connectors.json') : null;
291
+
292
+ if (!connectorsFile || !fs.existsSync(connectorsFile)) {
293
+ return { error: 'connectors.json not found. Run "wayfind context init" first.', personas: [] };
294
+ }
295
+
296
+ try {
297
+ const config = JSON.parse(fs.readFileSync(connectorsFile, 'utf8'));
298
+ const personas = config.personas || config.digest?.personas || [];
299
+ return { personas };
300
+ } catch (e) {
301
+ return { error: `Failed to read connectors.json: ${e.message}`, personas: [] };
302
+ }
303
+ }
304
+
305
+ function handleRecordFeedback(args) {
306
+ const { entry_id, helpful, query } = args;
307
+ const storePath = contentStore.resolveStorePath();
308
+
309
+ try {
310
+ const feedback = contentStore.loadFeedback(storePath);
311
+ const existing = feedback.entries || {};
312
+ existing[entry_id] = existing[entry_id] || { helpful: 0, unhelpful: 0, queries: [] };
313
+
314
+ if (helpful) {
315
+ existing[entry_id].helpful = (existing[entry_id].helpful || 0) + 1;
316
+ } else {
317
+ existing[entry_id].unhelpful = (existing[entry_id].unhelpful || 0) + 1;
318
+ }
319
+ if (query) {
320
+ existing[entry_id].queries = [...(existing[entry_id].queries || []).slice(-4), query];
321
+ }
322
+
323
+ contentStore.saveFeedback(storePath, { ...feedback, entries: existing });
324
+ return { recorded: true, entry_id, helpful };
325
+ } catch (e) {
326
+ return { error: `Failed to record feedback: ${e.message}` };
327
+ }
328
+ }
329
+
330
+ function handleAddContext(args) {
331
+ const { title, content, repo, tags = [] } = args;
332
+ const journalDir = contentStore.DEFAULT_JOURNAL_DIR;
333
+
334
+ if (!journalDir) {
335
+ return { error: 'Cannot resolve journal directory.' };
336
+ }
337
+
338
+ try {
339
+ const today = new Date().toISOString().slice(0, 10);
340
+ const sanitizedTitle = title.replace(/[^a-zA-Z0-9\s\-_]/g, '').slice(0, 60);
341
+ const repoLine = repo ? `\n**Repo:** ${repo}` : '';
342
+ const tagsLine = tags.length ? `\n**Tags:** ${tags.join(', ')}` : '';
343
+
344
+ const entry = [
345
+ `## ${repo || 'general'} — ${sanitizedTitle}`,
346
+ `**Why:** Added via Wayfind MCP`,
347
+ `**What:** ${content}`,
348
+ repoLine,
349
+ tagsLine,
350
+ ].filter(Boolean).join('\n');
351
+
352
+ const filePath = path.join(journalDir, `${today}.md`);
353
+ fs.mkdirSync(journalDir, { recursive: true });
354
+
355
+ const existing = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '';
356
+ fs.writeFileSync(filePath, existing + (existing ? '\n\n' : '') + entry + '\n');
357
+
358
+ return {
359
+ added: true,
360
+ date: today,
361
+ title: sanitizedTitle,
362
+ file: filePath,
363
+ hint: 'Run "wayfind index-journals" to make this entry searchable.',
364
+ };
365
+ } catch (e) {
366
+ return { error: `Failed to add context: ${e.message}` };
367
+ }
368
+ }
369
+
370
+ // ── Server setup ─────────────────────────────────────────────────────────────
371
+
372
+ const server = new Server(
373
+ { name: 'wayfind', version: pkg.version },
374
+ { capabilities: { tools: {} } }
375
+ );
376
+
377
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
378
+
379
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
380
+ const { name, arguments: args = {} } = request.params;
381
+
382
+ let result;
383
+ switch (name) {
384
+ case 'search_context': result = await handleSearchContext(args); break;
385
+ case 'get_entry': result = handleGetEntry(args); break;
386
+ case 'list_recent': result = handleListRecent(args); break;
387
+ case 'get_signals': result = handleGetSignals(args); break;
388
+ case 'get_team_status': result = handleGetTeamStatus(args); break;
389
+ case 'get_personas': result = handleGetPersonas(); break;
390
+ case 'record_feedback': result = handleRecordFeedback(args); break;
391
+ case 'add_context': result = handleAddContext(args); break;
392
+ default:
393
+ return { content: [{ type: 'text', text: JSON.stringify({ error: `Unknown tool: ${name}` }) }], isError: true };
394
+ }
395
+
396
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
397
+ });
398
+
399
+ // ── Entry point ──────────────────────────────────────────────────────────────
400
+
401
+ async function main() {
402
+ const transport = new StdioServerTransport();
403
+ await server.connect(transport);
404
+ // stderr so it doesn't pollute the MCP stdio channel
405
+ process.stderr.write(`Wayfind MCP server v${pkg.version} ready\n`);
406
+ }
407
+
408
+ main().catch((err) => {
409
+ process.stderr.write(`Fatal: ${err.message}\n`);
410
+ process.exit(1);
411
+ });
@@ -1109,7 +1109,7 @@ function parseCSArgs(args) {
1109
1109
  async function runIndexJournals(args) {
1110
1110
  const { opts } = parseCSArgs(args);
1111
1111
  const journalDir = opts.dir || contentStore.DEFAULT_JOURNAL_DIR;
1112
- const storePath = opts.store || contentStore.DEFAULT_STORE_PATH;
1112
+ const storePath = opts.store || contentStore.resolveStorePath();
1113
1113
 
1114
1114
  console.log(`Indexing journals from: ${journalDir}`);
1115
1115
  console.log(`Store: ${storePath}`);
@@ -1137,7 +1137,7 @@ async function runIndexJournals(args) {
1137
1137
  async function runIndexConversations(args) {
1138
1138
  const { opts } = parseCSArgs(args);
1139
1139
  const projectsDir = opts.dir || contentStore.DEFAULT_PROJECTS_DIR;
1140
- const storePath = opts.store || contentStore.DEFAULT_STORE_PATH;
1140
+ const storePath = opts.store || contentStore.resolveStorePath();
1141
1141
 
1142
1142
  console.log(`Indexing conversations from: ${projectsDir}`);
1143
1143
  console.log(`Store: ${storePath}`);
@@ -1322,7 +1322,7 @@ function buildRepoToTeamResolver() {
1322
1322
  async function runIndexConversationsWithExport(args, detectShifts = false) {
1323
1323
  const { opts } = parseCSArgs(args);
1324
1324
  const projectsDir = opts.dir || contentStore.DEFAULT_PROJECTS_DIR;
1325
- const storePath = opts.store || contentStore.DEFAULT_STORE_PATH;
1325
+ const storePath = opts.store || contentStore.resolveStorePath();
1326
1326
  const journalDir = opts.exportDir || contentStore.DEFAULT_JOURNAL_DIR;
1327
1327
 
1328
1328
  console.log(`Indexing conversations from: ${projectsDir}`);
@@ -2973,7 +2973,7 @@ async function runBot(args) {
2973
2973
 
2974
2974
  // Check content store has entries (warn if empty)
2975
2975
  const index = contentStore.loadIndex(
2976
- config.slack_bot.store_path || contentStore.DEFAULT_STORE_PATH
2976
+ config.slack_bot.store_path || contentStore.resolveStorePath()
2977
2977
  );
2978
2978
  if (!index || index.entryCount === 0) {
2979
2979
  console.log('Warning: Content store is empty. Run "wayfind index-journals" first for best results.');
@@ -3753,21 +3753,169 @@ function runPrompts(args) {
3753
3753
  const DEPLOY_TEMPLATES_DIR = path.join(ROOT, 'templates', 'deploy');
3754
3754
 
3755
3755
  async function runDeploy(args) {
3756
- const sub = args[0] || 'init';
3756
+ // Parse --team <teamId> flag
3757
+ const teamIdx = args.indexOf('--team');
3758
+ const teamId = teamIdx !== -1 ? args[teamIdx + 1] : null;
3759
+ const portIdx = args.indexOf('--port');
3760
+ const port = portIdx !== -1 ? parseInt(args[portIdx + 1], 10) : null;
3761
+
3762
+ const filteredArgs = args.filter((a, i) => {
3763
+ if (a === '--team' || a === '--port') return false;
3764
+ if (i > 0 && (args[i - 1] === '--team' || args[i - 1] === '--port')) return false;
3765
+ return true;
3766
+ });
3767
+ const sub = filteredArgs[0] || 'init';
3768
+
3757
3769
  switch (sub) {
3758
3770
  case 'init':
3759
- deployInit();
3771
+ if (teamId) {
3772
+ deployTeamInit(teamId, { port });
3773
+ } else {
3774
+ deployInit();
3775
+ }
3776
+ break;
3777
+ case 'list':
3778
+ deployList();
3760
3779
  break;
3761
3780
  case 'status':
3762
3781
  deployStatus();
3763
3782
  break;
3764
3783
  default:
3765
3784
  console.error(`Unknown deploy subcommand: ${sub}`);
3766
- console.error('Available: init, status');
3785
+ console.error('Available: init [--team <id>], list, status');
3767
3786
  process.exit(1);
3768
3787
  }
3769
3788
  }
3770
3789
 
3790
+ /**
3791
+ * Scaffold a per-team container config at ~/.claude/team-context/teams/<teamId>/deploy/
3792
+ */
3793
+ function deployTeamInit(teamId, { port } = {}) {
3794
+ const teamsBaseDir = HOME ? path.join(HOME, '.claude', 'team-context', 'teams') : null;
3795
+ if (!teamsBaseDir) {
3796
+ console.error('Cannot resolve home directory.');
3797
+ process.exit(1);
3798
+ }
3799
+
3800
+ const deployDir = path.join(teamsBaseDir, teamId, 'deploy');
3801
+ const storeDir = path.join(teamsBaseDir, teamId, 'content-store');
3802
+
3803
+ // Check for duplicate running container
3804
+ const psResult = spawnSync('docker', ['ps', '--filter', `label=com.wayfind.team=${teamId}`, '--format', '{{.Names}}'], { stdio: 'pipe' });
3805
+ const running = (psResult.stdout || '').toString().trim();
3806
+ if (running) {
3807
+ console.log(`Warning: container already running for team "${teamId}": ${running}`);
3808
+ console.log('Use "docker compose down" in the deploy directory before re-initializing.');
3809
+ process.exit(1);
3810
+ }
3811
+
3812
+ fs.mkdirSync(deployDir, { recursive: true });
3813
+ fs.mkdirSync(storeDir, { recursive: true });
3814
+ console.log(`Scaffolding deploy config for team: ${teamId}`);
3815
+ console.log(`Deploy dir: ${deployDir}`);
3816
+
3817
+ // Resolve team-context repo path for volume mount
3818
+ const config = readContextConfig();
3819
+ const teamEntry = config.teams && config.teams[teamId];
3820
+ const teamContextPath = teamEntry ? teamEntry.path : null;
3821
+
3822
+ // Assign port (default 3141; if taken, user should pass --port)
3823
+ const assignedPort = port || 3141;
3824
+ const containerName = `wayfind-${teamId}`;
3825
+
3826
+ // Build docker-compose.yml content with per-team overrides
3827
+ const templatePath = path.join(DEPLOY_TEMPLATES_DIR, 'docker-compose.yml');
3828
+ let composeContent = fs.readFileSync(templatePath, 'utf8');
3829
+ composeContent = composeContent
3830
+ .replace(/container_name: wayfind/, `container_name: ${containerName}`)
3831
+ .replace(/- "3141:3141"/, `- "${assignedPort}:3141"`)
3832
+ .replace(/(TEAM_CONTEXT_TENANT_ID:.*$)/m, `TEAM_CONTEXT_TENANT_ID: \${TEAM_CONTEXT_TENANT_ID:-${teamId}}`);
3833
+
3834
+ // Inject Docker label for discovery
3835
+ composeContent = composeContent.replace(
3836
+ /restart: unless-stopped/,
3837
+ `restart: unless-stopped\n labels:\n com.wayfind.team: "${teamId}"`
3838
+ );
3839
+
3840
+ const composePath = path.join(deployDir, 'docker-compose.yml');
3841
+ if (!fs.existsSync(composePath)) {
3842
+ fs.writeFileSync(composePath, composeContent, 'utf8');
3843
+ console.log(' docker-compose.yml — created');
3844
+ } else {
3845
+ console.log(' docker-compose.yml — already exists, skipping');
3846
+ }
3847
+
3848
+ // .env.example
3849
+ const envExampleSrc = path.join(DEPLOY_TEMPLATES_DIR, '.env.example');
3850
+ const envExampleDst = path.join(deployDir, '.env.example');
3851
+ if (!fs.existsSync(envExampleDst) && fs.existsSync(envExampleSrc)) {
3852
+ let envContent = fs.readFileSync(envExampleSrc, 'utf8');
3853
+ if (teamContextPath) {
3854
+ envContent += `\nTEAM_CONTEXT_TEAM_CONTEXT_PATH=${teamContextPath}\n`;
3855
+ }
3856
+ fs.writeFileSync(envExampleDst, envContent, 'utf8');
3857
+ console.log(' .env.example — created');
3858
+ }
3859
+
3860
+ // .env from .env.example
3861
+ const envPath = path.join(deployDir, '.env');
3862
+ if (!fs.existsSync(envPath) && fs.existsSync(envExampleDst)) {
3863
+ fs.copyFileSync(envExampleDst, envPath);
3864
+ console.log(' .env — created from .env.example (fill in your tokens)');
3865
+ }
3866
+
3867
+ const ghToken = detectGitHubToken();
3868
+ if (ghToken && fs.existsSync(envPath)) {
3869
+ let envContent = fs.readFileSync(envPath, 'utf8');
3870
+ if (!envContent.match(/^GITHUB_TOKEN=.+/m)) {
3871
+ envContent = envContent.replace(/^GITHUB_TOKEN=.*$/m, `GITHUB_TOKEN=${ghToken}`);
3872
+ if (!envContent.includes('GITHUB_TOKEN=')) envContent += `\nGITHUB_TOKEN=${ghToken}\n`;
3873
+ fs.writeFileSync(envPath, envContent, 'utf8');
3874
+ console.log(' GITHUB_TOKEN — auto-detected from gh CLI');
3875
+ }
3876
+ }
3877
+
3878
+ console.log('');
3879
+ console.log('Next steps:');
3880
+ console.log(` 1. Fill in ${deployDir}/.env with your tokens`);
3881
+ console.log(` 2. cd "${deployDir}" && docker compose up -d`);
3882
+ console.log(` 3. Verify: curl http://localhost:${assignedPort}/healthz`);
3883
+ console.log('');
3884
+ console.log(`Tip: run "wayfind deploy list" to see all running team containers.`);
3885
+
3886
+ telemetry.capture('deploy_team_init', { teamId }, CLI_USER);
3887
+ }
3888
+
3889
+ /**
3890
+ * List all running Wayfind team containers.
3891
+ */
3892
+ function deployList() {
3893
+ const psResult = spawnSync('docker', [
3894
+ 'ps',
3895
+ '--filter', 'label=com.wayfind.team',
3896
+ '--format', '{{.Names}}\t{{.Status}}\t{{.Label "com.wayfind.team"}}',
3897
+ ], { stdio: 'pipe' });
3898
+
3899
+ if (psResult.error) {
3900
+ console.log('Docker not available.');
3901
+ return;
3902
+ }
3903
+
3904
+ const rows = (psResult.stdout || '').toString().trim();
3905
+ if (!rows) {
3906
+ console.log('No Wayfind team containers running.');
3907
+ console.log('Start one with: wayfind deploy --team <teamId> && cd ~/.claude/team-context/teams/<teamId>/deploy && docker compose up -d');
3908
+ return;
3909
+ }
3910
+
3911
+ console.log('Running Wayfind team containers:');
3912
+ console.log('');
3913
+ for (const row of rows.split('\n')) {
3914
+ const [name, status, team] = row.split('\t');
3915
+ console.log(` ${name} (team: ${team || 'unknown'}) — ${status}`);
3916
+ }
3917
+ }
3918
+
3771
3919
  function deployInit() {
3772
3920
  const deployDir = path.join(process.cwd(), 'deploy');
3773
3921
 
@@ -3962,7 +4110,7 @@ function startHealthServer() {
3962
4110
  const server = http.createServer((req, res) => {
3963
4111
  if (req.url === '/healthz' && req.method === 'GET') {
3964
4112
  // Enrich with index freshness
3965
- const storePath = process.env.TEAM_CONTEXT_STORE_PATH || contentStore.DEFAULT_STORE_PATH;
4113
+ const storePath = contentStore.resolveStorePath();
3966
4114
  const index = contentStore.loadIndex(storePath);
3967
4115
  const indexInfo = index ? {
3968
4116
  entryCount: index.entryCount || 0,
@@ -4240,7 +4388,7 @@ async function indexConversationsIfAvailable() {
4240
4388
  }
4241
4389
 
4242
4390
  async function indexSignalsIfAvailable() {
4243
- const signalsDir = process.env.TEAM_CONTEXT_SIGNALS_DIR || contentStore.DEFAULT_SIGNALS_DIR;
4391
+ const signalsDir = contentStore.resolveSignalsDir();
4244
4392
  if (!signalsDir || !fs.existsSync(signalsDir)) {
4245
4393
  console.log(`No signals at ${signalsDir} — skipping index.`);
4246
4394
  return;
@@ -4310,9 +4458,9 @@ function ensureContainerConfig() {
4310
4458
  api_key_env: 'ANTHROPIC_API_KEY',
4311
4459
  },
4312
4460
  lookback_days: 7,
4313
- store_path: process.env.TEAM_CONTEXT_STORE_PATH || contentStore.DEFAULT_STORE_PATH,
4461
+ store_path: contentStore.resolveStorePath(),
4314
4462
  journal_dir: process.env.TEAM_CONTEXT_JOURNALS_DIR || '/data/journals',
4315
- signals_dir: process.env.TEAM_CONTEXT_SIGNALS_DIR || contentStore.DEFAULT_SIGNALS_DIR,
4463
+ signals_dir: contentStore.resolveSignalsDir(),
4316
4464
  team_context_dir: process.env.TEAM_CONTEXT_TEAM_CONTEXT_DIR || '',
4317
4465
  slack: {
4318
4466
  webhook_url: process.env.TEAM_CONTEXT_SLACK_WEBHOOK || '',
@@ -4326,9 +4474,9 @@ function ensureContainerConfig() {
4326
4474
  // may have host paths that don't exist inside the container
4327
4475
  if (config.digest) {
4328
4476
  const containerPaths = {
4329
- store_path: process.env.TEAM_CONTEXT_STORE_PATH || contentStore.DEFAULT_STORE_PATH,
4477
+ store_path: contentStore.resolveStorePath(),
4330
4478
  journal_dir: process.env.TEAM_CONTEXT_JOURNALS_DIR || '/data/journals',
4331
- signals_dir: process.env.TEAM_CONTEXT_SIGNALS_DIR || contentStore.DEFAULT_SIGNALS_DIR,
4479
+ signals_dir: contentStore.resolveSignalsDir(),
4332
4480
  team_context_dir: process.env.TEAM_CONTEXT_TEAM_CONTEXT_DIR || '',
4333
4481
  };
4334
4482
  for (const [key, val] of Object.entries(containerPaths)) {
@@ -4345,7 +4493,7 @@ function ensureContainerConfig() {
4345
4493
  bot_token_env: 'SLACK_BOT_TOKEN',
4346
4494
  app_token_env: 'SLACK_APP_TOKEN',
4347
4495
  mode: process.env.TEAM_CONTEXT_BOT_MODE || 'local',
4348
- store_path: process.env.TEAM_CONTEXT_STORE_PATH || contentStore.DEFAULT_STORE_PATH,
4496
+ store_path: contentStore.resolveStorePath(),
4349
4497
  journal_dir: process.env.TEAM_CONTEXT_JOURNALS_DIR || '/data/journals',
4350
4498
  llm: {
4351
4499
  provider: 'anthropic',
@@ -4359,7 +4507,7 @@ function ensureContainerConfig() {
4359
4507
  // Override container-specific paths in bot config (same reason as digest above)
4360
4508
  if (config.slack_bot) {
4361
4509
  const botPaths = {
4362
- store_path: process.env.TEAM_CONTEXT_STORE_PATH || contentStore.DEFAULT_STORE_PATH,
4510
+ store_path: contentStore.resolveStorePath(),
4363
4511
  journal_dir: process.env.TEAM_CONTEXT_JOURNALS_DIR || '/data/journals',
4364
4512
  };
4365
4513
  for (const [key, val] of Object.entries(botPaths)) {
@@ -4438,7 +4586,7 @@ function buildBotConfigFromEnv() {
4438
4586
  bot_token_env: 'SLACK_BOT_TOKEN',
4439
4587
  app_token_env: 'SLACK_APP_TOKEN',
4440
4588
  mode: process.env.TEAM_CONTEXT_BOT_MODE || 'local',
4441
- store_path: process.env.TEAM_CONTEXT_STORE_PATH || contentStore.DEFAULT_STORE_PATH,
4589
+ store_path: contentStore.resolveStorePath(),
4442
4590
  journal_dir: process.env.TEAM_CONTEXT_JOURNALS_DIR || '/data/journals',
4443
4591
  llm: {
4444
4592
  provider: 'anthropic',
@@ -4664,6 +4812,194 @@ function runCheckVersion() {
4664
4812
  }, CLI_USER);
4665
4813
  }
4666
4814
 
4815
+ // ── Container doctor ────────────────────────────────────────────────────────
4816
+
4817
+ /**
4818
+ * Detect if we're running inside a Docker container.
4819
+ */
4820
+ function isRunningInContainer() {
4821
+ try {
4822
+ return fs.existsSync('/.dockerenv');
4823
+ } catch {
4824
+ return false;
4825
+ }
4826
+ }
4827
+
4828
+ /**
4829
+ * Container-specific health checks. Prints PASS/WARN lines and exits with
4830
+ * appropriate code. Useful as a post-startup self-check or via
4831
+ * `docker exec wayfind npx wayfind doctor --container`.
4832
+ */
4833
+ async function runContainerDoctor() {
4834
+ const GREEN = '\x1b[32m', YELLOW = '\x1b[33m', RED = '\x1b[31m', RESET = '\x1b[0m';
4835
+ const pass = (msg) => console.log(`${GREEN}PASS${RESET} ${msg}`);
4836
+ const warn = (msg) => console.log(`${YELLOW}WARN${RESET} ${msg}`);
4837
+ let issues = 0;
4838
+
4839
+ console.log('');
4840
+ console.log('Wayfind — Container Doctor');
4841
+ console.log('══════════════════════════════');
4842
+
4843
+ // 1. Backend type — is SQLite active or JSON fallback?
4844
+ const storePath = path.join(EFFECTIVE_DIR, 'content-store');
4845
+ try {
4846
+ const storage = require('./storage/index.js');
4847
+ storage.getBackend(storePath);
4848
+ const info = storage.getBackendInfo(storePath);
4849
+ if (!info) {
4850
+ warn('Storage backend: not initialized');
4851
+ issues++;
4852
+ } else if (info.type === 'sqlite' && !info.fallback) {
4853
+ pass(`Storage backend: sqlite`);
4854
+ } else if (info.type === 'json' && info.fallback) {
4855
+ warn('Storage backend: JSON (fallback — SQLite failed to load)');
4856
+ console.log(' Install better-sqlite3 or rebuild the container image');
4857
+ issues++;
4858
+ } else if (info.type === 'json') {
4859
+ warn('Storage backend: JSON (not SQLite)');
4860
+ issues++;
4861
+ } else {
4862
+ pass(`Storage backend: ${info.type}`);
4863
+ }
4864
+ } catch (e) {
4865
+ warn(`Storage backend: error — ${e.message}`);
4866
+ issues++;
4867
+ }
4868
+
4869
+ // 2. Entry count — are there entries in the content store?
4870
+ let entryCount = 0;
4871
+ try {
4872
+ const storage = require('./storage/index.js');
4873
+ const backend = storage.getBackend(storePath);
4874
+ const idx = backend.loadIndex();
4875
+ if (idx && idx.entries) {
4876
+ entryCount = Object.keys(idx.entries).length;
4877
+ }
4878
+ if (entryCount > 0) {
4879
+ pass(`Content store: ${entryCount} entries`);
4880
+ } else {
4881
+ warn('Content store: 0 entries — no signals have been indexed');
4882
+ console.log(' Run: wayfind pull --all');
4883
+ issues++;
4884
+ }
4885
+ } catch (e) {
4886
+ warn(`Content store: error — ${e.message}`);
4887
+ issues++;
4888
+ }
4889
+
4890
+ // 3. Embedding coverage — what % of entries have embeddings?
4891
+ if (entryCount > 0) {
4892
+ try {
4893
+ const storage = require('./storage/index.js');
4894
+ const backend = storage.getBackend(storePath);
4895
+ const idx = backend.loadIndex();
4896
+ let total = 0, embedded = 0;
4897
+ if (idx && idx.entries) {
4898
+ for (const e of Object.values(idx.entries)) {
4899
+ total++;
4900
+ if (e.hasEmbedding) embedded++;
4901
+ }
4902
+ }
4903
+ const pct = total > 0 ? Math.round(100 * embedded / total) : 0;
4904
+ if (pct >= 50) {
4905
+ pass(`Embedding coverage: ${embedded}/${total} (${pct}%)`);
4906
+ } else {
4907
+ warn(`Embedding coverage: ${embedded}/${total} (${pct}%) — search quality degraded`);
4908
+ console.log(' Run: wayfind reindex');
4909
+ issues++;
4910
+ }
4911
+ } catch (e) {
4912
+ warn(`Embedding coverage: error — ${e.message}`);
4913
+ issues++;
4914
+ }
4915
+ }
4916
+
4917
+ // 4. Signal freshness — are there signal files from today?
4918
+ const signalsDir = path.join(EFFECTIVE_DIR, 'signals');
4919
+ const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
4920
+ try {
4921
+ if (!fs.existsSync(signalsDir)) {
4922
+ warn('Signal freshness: no signals directory');
4923
+ issues++;
4924
+ } else {
4925
+ const channels = fs.readdirSync(signalsDir).filter((f) => {
4926
+ try { return fs.statSync(path.join(signalsDir, f)).isDirectory(); } catch { return false; }
4927
+ });
4928
+ if (channels.length === 0) {
4929
+ warn('Signal freshness: no signal channels found');
4930
+ issues++;
4931
+ } else {
4932
+ let freshCount = 0;
4933
+ for (const ch of channels) {
4934
+ const chDir = path.join(signalsDir, ch);
4935
+ const files = fs.readdirSync(chDir).filter((f) => f.endsWith('.md')).sort().reverse();
4936
+ const newest = files[0] || '';
4937
+ // Signal files are named with date prefix, e.g. 2026-03-28-....md
4938
+ if (newest.startsWith(today)) {
4939
+ freshCount++;
4940
+ }
4941
+ }
4942
+ if (freshCount === channels.length) {
4943
+ pass(`Signal freshness: ${freshCount}/${channels.length} channels have signals from today`);
4944
+ } else {
4945
+ warn(`Signal freshness: ${freshCount}/${channels.length} channels have signals from today`);
4946
+ console.log(' Run: wayfind pull --all');
4947
+ issues++;
4948
+ }
4949
+ }
4950
+ }
4951
+ } catch (e) {
4952
+ warn(`Signal freshness: error — ${e.message}`);
4953
+ issues++;
4954
+ }
4955
+
4956
+ // 5. Slack bot / health endpoint — is /healthz responding?
4957
+ const healthPort = parseInt(process.env.TEAM_CONTEXT_HEALTH_PORT || '3141', 10);
4958
+ try {
4959
+ const healthResult = await new Promise((resolve) => {
4960
+ const req = http.get(`http://localhost:${healthPort}/healthz`, { timeout: 3000 }, (res) => {
4961
+ let body = '';
4962
+ res.on('data', (chunk) => { body += chunk; });
4963
+ res.on('end', () => resolve({ status: res.statusCode, body }));
4964
+ });
4965
+ req.on('error', (e) => resolve({ status: 0, error: e.message }));
4966
+ req.on('timeout', () => { req.destroy(); resolve({ status: 0, error: 'timeout' }); });
4967
+ });
4968
+
4969
+ if (healthResult.status === 200) {
4970
+ let detail = '';
4971
+ try {
4972
+ const data = JSON.parse(healthResult.body);
4973
+ if (data.slack && data.slack.connected) {
4974
+ detail = ' (Slack connected)';
4975
+ } else if (data.slack && !data.slack.connected) {
4976
+ detail = ' (Slack disconnected)';
4977
+ }
4978
+ } catch { /* ignore parse errors */ }
4979
+ pass(`Health endpoint: http://localhost:${healthPort}/healthz${detail}`);
4980
+ } else if (healthResult.status > 0) {
4981
+ warn(`Health endpoint: responded with ${healthResult.status}`);
4982
+ issues++;
4983
+ } else {
4984
+ warn(`Health endpoint: not reachable — ${healthResult.error}`);
4985
+ console.log(' Is the container running? Check: docker ps');
4986
+ issues++;
4987
+ }
4988
+ } catch (e) {
4989
+ warn(`Health endpoint: error — ${e.message}`);
4990
+ issues++;
4991
+ }
4992
+
4993
+ console.log('');
4994
+ console.log('══════════════════════════════');
4995
+ if (issues === 0) {
4996
+ console.log(`${GREEN}All container checks passed${RESET}`);
4997
+ } else {
4998
+ console.log(`${YELLOW}${issues} issue(s) found${RESET}`);
4999
+ process.exit(1);
5000
+ }
5001
+ }
5002
+
4667
5003
  // ── Command registry ────────────────────────────────────────────────────────
4668
5004
 
4669
5005
  const COMMANDS = {
@@ -4741,48 +5077,92 @@ const COMMANDS = {
4741
5077
  process.exit(result.status);
4742
5078
  }
4743
5079
 
4744
- // Step 3: Update Wayfind container if one is running
5080
+ // Step 3: Update all Wayfind containers (per-team labeled + legacy 'wayfind')
4745
5081
  const dockerCheck = spawnSync('docker', ['compose', 'version'], { stdio: 'pipe' });
4746
5082
  if (!dockerCheck.error && dockerCheck.status === 0) {
4747
- const psResult = spawnSync('docker', ['ps', '--filter', 'name=wayfind', '--format', '{{.Names}}'], { stdio: 'pipe' });
4748
- const containers = (psResult.stdout || '').toString().trim();
4749
- if (containers && containers.split('\n').some(c => c === 'wayfind')) {
4750
- console.log('\nWayfind container detected — pulling latest image...');
4751
-
4752
- // Find compose file directory: check container labels, then known paths
4753
- let composeDir = '';
4754
- const inspectResult = spawnSync('docker', ['inspect', 'wayfind', '--format', '{{index .Config.Labels "com.docker.compose.project.working_dir"}}'], { stdio: 'pipe' });
4755
- const labelDir = (inspectResult.stdout || '').toString().trim();
4756
- if (labelDir && fs.existsSync(path.join(labelDir, 'docker-compose.yml'))) {
4757
- composeDir = labelDir;
4758
- } else {
4759
- const teamCtx = process.env.TEAM_CONTEXT_TEAM_CONTEXT_DIR || '';
4760
- const candidates = [
4761
- teamCtx ? path.join(teamCtx, 'deploy') : '',
4762
- process.cwd(),
4763
- path.join(HOME, 'team-context', 'deploy'),
4764
- ].filter(Boolean);
4765
- for (const dir of candidates) {
4766
- if (fs.existsSync(path.join(dir, 'docker-compose.yml'))) {
4767
- composeDir = dir;
4768
- break;
5083
+ // Discover containers: prefer com.wayfind.team label, fall back to legacy 'wayfind' name
5084
+ const labeledResult = spawnSync('docker', [
5085
+ 'ps',
5086
+ '--filter', 'label=com.wayfind.team',
5087
+ '--format', '{{.Names}}\t{{index .Labels "com.wayfind.team"}}',
5088
+ ], { stdio: 'pipe' });
5089
+ const legacyResult = spawnSync('docker', ['ps', '--filter', 'name=^wayfind$', '--format', '{{.Names}}\t'], { stdio: 'pipe' });
5090
+
5091
+ const containerRows = [
5092
+ ...(labeledResult.stdout || '').toString().trim().split('\n').filter(Boolean),
5093
+ ...(legacyResult.stdout || '').toString().trim().split('\n').filter(Boolean),
5094
+ ];
5095
+
5096
+ if (containerRows.length > 0) {
5097
+ console.log(`\nWayfind container(s) detected — updating ${containerRows.length} container(s)...`);
5098
+
5099
+ for (const row of containerRows) {
5100
+ const [containerName] = row.split('\t');
5101
+ if (!containerName) continue;
5102
+
5103
+ // Find compose dir via docker label
5104
+ const inspectResult = spawnSync('docker', [
5105
+ 'inspect', containerName,
5106
+ '--format', '{{index .Config.Labels "com.docker.compose.project.working_dir"}}',
5107
+ ], { stdio: 'pipe' });
5108
+ const labelDir = (inspectResult.stdout || '').toString().trim();
5109
+
5110
+ let composeDir = '';
5111
+ if (labelDir && fs.existsSync(path.join(labelDir, 'docker-compose.yml'))) {
5112
+ composeDir = labelDir;
5113
+ } else {
5114
+ // For per-team containers, check teams dir
5115
+ const teamsBase = HOME ? path.join(HOME, '.claude', 'team-context', 'teams') : '';
5116
+ if (teamsBase && fs.existsSync(teamsBase)) {
5117
+ for (const tid of fs.readdirSync(teamsBase)) {
5118
+ const candidate = path.join(teamsBase, tid, 'deploy');
5119
+ if (fs.existsSync(path.join(candidate, 'docker-compose.yml'))) {
5120
+ // Check if this compose file manages this container
5121
+ const checkResult = spawnSync('docker', ['compose', 'ps', '--format', '{{.Name}}'], { cwd: candidate, stdio: 'pipe' });
5122
+ const composeContainers = (checkResult.stdout || '').toString();
5123
+ if (composeContainers.includes(containerName)) {
5124
+ composeDir = candidate;
5125
+ break;
5126
+ }
5127
+ }
5128
+ }
5129
+ }
5130
+ // Legacy fallback
5131
+ if (!composeDir) {
5132
+ const legacyCandidates = [process.cwd(), path.join(HOME || '', 'team-context', 'deploy')];
5133
+ for (const dir of legacyCandidates) {
5134
+ if (dir && fs.existsSync(path.join(dir, 'docker-compose.yml'))) {
5135
+ composeDir = dir;
5136
+ break;
5137
+ }
5138
+ }
4769
5139
  }
4770
5140
  }
4771
- }
4772
5141
 
4773
- if (composeDir) {
4774
- console.log(`Using compose file in: ${composeDir}`);
4775
- const pullResult = spawnSync('docker', ['compose', 'pull'], { cwd: composeDir, stdio: 'inherit' });
4776
- if (!pullResult.error && pullResult.status === 0) {
4777
- console.log('Recreating container with new image...');
4778
- spawnSync('docker', ['compose', 'up', '-d'], { cwd: composeDir, stdio: 'inherit' });
4779
- console.log('Container updated.');
5142
+ if (composeDir) {
5143
+ console.log(`\nUpdating ${containerName} (compose: ${composeDir})...`);
5144
+ const pullResult = spawnSync('docker', ['compose', 'pull'], { cwd: composeDir, stdio: 'inherit' });
5145
+ if (!pullResult.error && pullResult.status === 0) {
5146
+ spawnSync('docker', ['compose', 'up', '-d'], { cwd: composeDir, stdio: 'inherit' });
5147
+ console.log(`${containerName} updated.`);
5148
+
5149
+ // Post-deploy smoke check
5150
+ spawnSync('sleep', ['5']);
5151
+ const logsResult = spawnSync('docker', ['logs', '--tail', '50', containerName], { stdio: 'pipe' });
5152
+ const logOutput = (logsResult.stdout || '').toString() + (logsResult.stderr || '').toString();
5153
+ const warnings = logOutput.split('\n').filter(l => /Warning:|Error:|failed|fallback/i.test(l) && l.trim());
5154
+ if (warnings.length > 0) {
5155
+ warnings.forEach(w => console.log(` \u26A0 ${w.trim()}`));
5156
+ console.log(' Post-deploy warnings detected — review above');
5157
+ } else {
5158
+ console.log(' Post-deploy check: no warnings');
5159
+ }
5160
+ } else {
5161
+ console.error(`Docker pull failed for ${containerName}.`);
5162
+ }
4780
5163
  } else {
4781
- console.error('Docker pull failed container not updated.');
5164
+ console.log(`Could not locate docker-compose.yml for ${containerName}. Update manually.`);
4782
5165
  }
4783
- } else {
4784
- console.log('Could not locate docker-compose.yml for the Wayfind container.');
4785
- console.log('Run "docker compose pull && docker compose up -d" manually in your deploy directory.');
4786
5166
  }
4787
5167
  }
4788
5168
  }
@@ -4829,9 +5209,13 @@ const COMMANDS = {
4829
5209
  run: (args) => runMigrateToPlugin(args),
4830
5210
  },
4831
5211
  doctor: {
4832
- desc: 'Check your Wayfind installation for issues',
5212
+ desc: 'Check your Wayfind installation for issues (--container for container checks)',
4833
5213
  run: (args) => {
4834
- spawn('bash', [path.join(ROOT, 'doctor.sh'), ...args]);
5214
+ if (args.includes('--container') || isRunningInContainer()) {
5215
+ runContainerDoctor();
5216
+ } else {
5217
+ spawn('bash', [path.join(ROOT, 'doctor.sh'), ...args]);
5218
+ }
4835
5219
  },
4836
5220
  },
4837
5221
  version: {
@@ -5210,6 +5594,7 @@ function showHelp() {
5210
5594
  console.log(' wayfind update Update from npm, re-sync hooks, update container');
5211
5595
  console.log(' wayfind migrate-to-plugin Remove old hooks/commands — let the plugin handle them');
5212
5596
  console.log(' wayfind doctor Check installation health');
5597
+ console.log(' wayfind doctor --container Container-specific health checks (auto-detected in Docker)');
5213
5598
  console.log('');
5214
5599
  console.log('Publishing:');
5215
5600
  console.log(' wayfind sync-public Sync code to usewayfind/wayfind (triggers npm + Docker publish)');
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "wayfind",
3
- "version": "2.0.38",
3
+ "version": "2.0.40",
4
4
  "description": "Team decision trail for AI-assisted development. The connective tissue between product, engineering, and strategy.",
5
5
  "bin": {
6
- "wayfind": "./bin/team-context.js"
6
+ "wayfind": "./bin/team-context.js",
7
+ "wayfind-mcp": "./bin/mcp-server.js"
7
8
  },
8
9
  "keywords": [
9
10
  "ai",
@@ -48,6 +49,7 @@
48
49
  "node": ">=16"
49
50
  },
50
51
  "dependencies": {
52
+ "@modelcontextprotocol/sdk": "^1.28.0",
51
53
  "@slack/bolt": "^4.6.0",
52
54
  "posthog-node": "^5.28.0"
53
55
  },