wayfind 0.0.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/BOOTSTRAP_PROMPT.md +120 -0
  2. package/bin/connectors/github.js +617 -0
  3. package/bin/connectors/index.js +13 -0
  4. package/bin/connectors/intercom.js +595 -0
  5. package/bin/connectors/llm.js +469 -0
  6. package/bin/connectors/notion.js +747 -0
  7. package/bin/connectors/transport.js +325 -0
  8. package/bin/content-store.js +2006 -0
  9. package/bin/digest.js +813 -0
  10. package/bin/rebuild-status.js +297 -0
  11. package/bin/slack-bot.js +1535 -0
  12. package/bin/slack.js +342 -0
  13. package/bin/storage/index.js +171 -0
  14. package/bin/storage/json-backend.js +348 -0
  15. package/bin/storage/sqlite-backend.js +415 -0
  16. package/bin/team-context.js +4209 -0
  17. package/bin/telemetry.js +159 -0
  18. package/doctor.sh +291 -0
  19. package/install.sh +144 -0
  20. package/journal-summary.sh +577 -0
  21. package/package.json +48 -6
  22. package/setup.sh +641 -0
  23. package/specializations/claude-code/CLAUDE.md-global-fragment.md +53 -0
  24. package/specializations/claude-code/CLAUDE.md-repo-fragment.md +16 -0
  25. package/specializations/claude-code/README.md +99 -0
  26. package/specializations/claude-code/commands/doctor.md +31 -0
  27. package/specializations/claude-code/commands/init-memory.md +154 -0
  28. package/specializations/claude-code/commands/init-team.md +415 -0
  29. package/specializations/claude-code/commands/journal.md +66 -0
  30. package/specializations/claude-code/commands/review-prs.md +119 -0
  31. package/specializations/claude-code/hooks/check-global-state.sh +20 -0
  32. package/specializations/claude-code/hooks/session-end.sh +36 -0
  33. package/specializations/claude-code/settings.json +15 -0
  34. package/specializations/cursor/README.md +120 -0
  35. package/specializations/cursor/global-rule.mdc +53 -0
  36. package/specializations/cursor/repo-rule.mdc +25 -0
  37. package/specializations/generic/README.md +47 -0
  38. package/templates/autopilot/design.md +22 -0
  39. package/templates/autopilot/engineering.md +22 -0
  40. package/templates/autopilot/product.md +22 -0
  41. package/templates/autopilot/strategy.md +22 -0
  42. package/templates/autopilot/unified.md +24 -0
  43. package/templates/deploy/.env.example +110 -0
  44. package/templates/deploy/docker-compose.yml +63 -0
  45. package/templates/deploy/slack-app-manifest.json +45 -0
  46. package/templates/github-actions/meridian-digest.yml +85 -0
  47. package/templates/global.md +79 -0
  48. package/templates/memory-file.md +18 -0
  49. package/templates/personal-state.md +14 -0
  50. package/templates/personas.json +28 -0
  51. package/templates/product-state.md +41 -0
  52. package/templates/prompts-readme.md +19 -0
  53. package/templates/repo-state.md +18 -0
  54. package/templates/session-protocol-fragment.md +46 -0
  55. package/templates/slack-app-manifest.json +27 -0
  56. package/templates/statusline.sh +22 -0
  57. package/templates/strategy-state.md +39 -0
  58. package/templates/team-state.md +55 -0
  59. package/uninstall.sh +105 -0
  60. package/README.md +0 -4
@@ -0,0 +1,4209 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { spawnSync, spawn: spawnChild } = require('child_process');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+ const crypto = require('crypto');
7
+ const os = require('os');
8
+ const http = require('http');
9
+ const readline = require('readline');
10
+
11
+ const HOME = process.env.HOME || process.env.USERPROFILE;
12
+ if (!HOME) {
13
+ console.error('Error: HOME environment variable is not set.');
14
+ process.exit(1);
15
+ }
16
+
17
+ const WAYFIND_DIR = path.join(HOME, '.claude', 'team-context');
18
+
19
+ // --- Env var migration shim (v2.0.0) ---
20
+ // Honor old MERIDIAN_* env vars with deprecation warning. Remove in v3.0.
21
+ const ENV_VAR_MIGRATION = {
22
+ MERIDIAN_TELEMETRY: 'TEAM_CONTEXT_TELEMETRY',
23
+ MERIDIAN_AUTHOR: 'TEAM_CONTEXT_AUTHOR',
24
+ MERIDIAN_TENANT_ID: 'TEAM_CONTEXT_TENANT_ID',
25
+ MERIDIAN_TEAM_CONTEXT_DIR: 'TEAM_CONTEXT_DIR',
26
+ MERIDIAN_JOURNALS_DIR: 'TEAM_CONTEXT_JOURNALS_DIR',
27
+ MERIDIAN_SIGNALS_DIR: 'TEAM_CONTEXT_SIGNALS_DIR',
28
+ MERIDIAN_SKIP_EXPORT: 'TEAM_CONTEXT_SKIP_EXPORT',
29
+ MERIDIAN_EXCLUDE_REPOS: 'TEAM_CONTEXT_EXCLUDE_REPOS',
30
+ MERIDIAN_SLACK_WEBHOOK: 'TEAM_CONTEXT_SLACK_WEBHOOK',
31
+ MERIDIAN_LLM_MODEL: 'TEAM_CONTEXT_LLM_MODEL',
32
+ MERIDIAN_DIGEST_SCHEDULE: 'TEAM_CONTEXT_DIGEST_SCHEDULE',
33
+ MERIDIAN_SIGNAL_SCHEDULE: 'TEAM_CONTEXT_SIGNAL_SCHEDULE',
34
+ MERIDIAN_STORAGE_BACKEND: 'TEAM_CONTEXT_STORAGE_BACKEND',
35
+ MERIDIAN_MODE: 'TEAM_CONTEXT_MODE',
36
+ MERIDIAN_REINDEX_SCHEDULE: 'TEAM_CONTEXT_REINDEX_SCHEDULE',
37
+ MERIDIAN_ENCRYPTION_KEY: 'TEAM_CONTEXT_ENCRYPTION_KEY',
38
+ MERIDIAN_SIMULATE: 'TEAM_CONTEXT_SIMULATE',
39
+ MERIDIAN_SIM_FIXTURES: 'TEAM_CONTEXT_SIM_FIXTURES',
40
+ MERIDIAN_VERSION: 'TEAM_CONTEXT_VERSION',
41
+ };
42
+ for (const [oldKey, newKey] of Object.entries(ENV_VAR_MIGRATION)) {
43
+ if (process.env[oldKey] && !process.env[newKey]) {
44
+ process.env[newKey] = process.env[oldKey];
45
+ if (!process.env.TEAM_CONTEXT_SKIP_EXPORT) {
46
+ console.warn(`⚠ ${oldKey} is deprecated — rename to ${newKey}`);
47
+ }
48
+ }
49
+ }
50
+
51
+ // Also migrate config directory: if old ~/.claude/meridian/ exists but new doesn't, use old
52
+ const OLD_DIR = path.join(HOME, '.claude', 'meridian');
53
+ const EFFECTIVE_DIR = (fs.existsSync(WAYFIND_DIR) || !fs.existsSync(OLD_DIR)) ? WAYFIND_DIR : OLD_DIR;
54
+ if (EFFECTIVE_DIR === OLD_DIR) {
55
+ console.warn('⚠ ~/.claude/meridian/ detected — rename to ~/.claude/team-context/');
56
+ }
57
+
58
+ // Auto-load .env from config dir BEFORE requiring modules
59
+ // (modules like content-store read env vars at load time)
60
+ const ENV_FILE = path.join(EFFECTIVE_DIR, '.env');
61
+ if (fs.existsSync(ENV_FILE)) {
62
+ for (const line of fs.readFileSync(ENV_FILE, 'utf8').split('\n')) {
63
+ const trimmed = line.trim();
64
+ if (!trimmed || trimmed.startsWith('#')) continue;
65
+ const eq = trimmed.indexOf('=');
66
+ if (eq === -1) continue;
67
+ const key = trimmed.slice(0, eq).trim();
68
+ const val = trimmed.slice(eq + 1).trim().replace(/^["']|["']$/g, '');
69
+ if (!process.env[key]) process.env[key] = val;
70
+ }
71
+ }
72
+
73
+ const connectors = require('./connectors');
74
+ const digest = require('./digest');
75
+ const slack = require('./slack');
76
+ const slackBot = require('./slack-bot');
77
+ const contentStore = require('./content-store');
78
+ const rebuildStatus = require('./rebuild-status');
79
+ const telemetry = require('./telemetry');
80
+
81
+ process.on('beforeExit', async () => { await telemetry.flush(); });
82
+
83
+ const ROOT = path.join(__dirname, '..');
84
+ const DEFAULT_PERSONAS_PATH = path.join(ROOT, 'templates', 'personas.json');
85
+
86
+ const CLI_USER = process.env.TEAM_CONTEXT_AUTHOR || 'system';
87
+ const TEAM_FILE = path.join(WAYFIND_DIR, 'team.json');
88
+ const PROFILE_FILE = path.join(WAYFIND_DIR, 'profile.json');
89
+ const CONNECTORS_FILE = path.join(WAYFIND_DIR, 'connectors.json');
90
+
91
+ // ── Persona config resolution ────────────────────────────────────────────────
92
+ // User config lives at ~/.claude/team-context/personas.json (Claude Code) or
93
+ // ~/.ai-memory/team-context/personas.json (Cursor/generic). Falls back to the
94
+ // bundled default in templates/personas.json.
95
+
96
+ function getPersonasConfigPath() {
97
+ const candidates = [
98
+ path.join(HOME, '.claude', 'team-context', 'personas.json'),
99
+ path.join(HOME, '.ai-memory', 'team-context', 'personas.json'),
100
+ ];
101
+ for (const p of candidates) {
102
+ if (fs.existsSync(p)) return p;
103
+ }
104
+ return null;
105
+ }
106
+
107
+ function getPersonasPath() {
108
+ return getPersonasConfigPath() || DEFAULT_PERSONAS_PATH;
109
+ }
110
+
111
+ function readPersonas() {
112
+ const configPath = getPersonasPath();
113
+ const data = readJSONFile(configPath);
114
+ if (!data) {
115
+ console.error(`Error reading personas config: ${configPath}`);
116
+ process.exit(1);
117
+ }
118
+ return data;
119
+ }
120
+
121
+ function writePersonas(configPath, data) {
122
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
123
+ fs.writeFileSync(configPath, JSON.stringify(data, null, 2) + '\n', 'utf8');
124
+ }
125
+
126
+ function ensureUserConfig() {
127
+ const existing = getPersonasConfigPath();
128
+ if (existing) return existing;
129
+ // Copy default to the first candidate location
130
+ const dest = path.join(HOME, '.claude', 'team-context', 'personas.json');
131
+ const data = JSON.parse(fs.readFileSync(DEFAULT_PERSONAS_PATH, 'utf8'));
132
+ writePersonas(dest, data);
133
+ return dest;
134
+ }
135
+
136
+ // ── Personas command ─────────────────────────────────────────────────────────
137
+
138
+ function runPersonas(args) {
139
+ if (args.includes('--reset')) {
140
+ const dest = ensureUserConfig();
141
+ const defaults = JSON.parse(fs.readFileSync(DEFAULT_PERSONAS_PATH, 'utf8'));
142
+ writePersonas(dest, defaults);
143
+ console.log(`Personas reset to defaults (${defaults.personas.length} personas).`);
144
+ console.log(`Config: ${dest}`);
145
+ return;
146
+ }
147
+
148
+ const addIdx = args.indexOf('--add');
149
+ if (addIdx !== -1) {
150
+ const id = args[addIdx + 1];
151
+ const name = args[addIdx + 2];
152
+ if (!id || !name) {
153
+ console.error('Usage: wayfind personas --add <id> <name> [description]');
154
+ process.exit(1);
155
+ }
156
+ if (!/^[a-z][a-z0-9-]*$/.test(id)) {
157
+ console.error(`Invalid persona ID "${id}". Use lowercase letters, numbers, and hyphens (must start with a letter).`);
158
+ process.exit(1);
159
+ }
160
+ const description = args.slice(addIdx + 3).join(' ') || `${name} perspective`;
161
+ const configPath = ensureUserConfig();
162
+ const data = readPersonas();
163
+ if (data.personas.some((p) => p.id === id)) {
164
+ console.error(`Persona with id "${id}" already exists.`);
165
+ process.exit(1);
166
+ }
167
+ data.personas.push({ id, name, description, autopilot: true });
168
+ writePersonas(configPath, data);
169
+ console.log(`Added persona: ${name} (${id})`);
170
+ console.log(`Config: ${configPath}`);
171
+ return;
172
+ }
173
+
174
+ const removeIdx = args.indexOf('--remove');
175
+ if (removeIdx !== -1) {
176
+ const id = args[removeIdx + 1];
177
+ if (!id) {
178
+ console.error('Usage: wayfind personas --remove <id>');
179
+ process.exit(1);
180
+ }
181
+ const configPath = ensureUserConfig();
182
+ const data = readPersonas();
183
+ const before = data.personas.length;
184
+ data.personas = data.personas.filter((p) => p.id !== id);
185
+ if (data.personas.length === before) {
186
+ console.error(`No persona found with id "${id}".`);
187
+ process.exit(1);
188
+ }
189
+ writePersonas(configPath, data);
190
+ console.log(`Removed persona: ${id}`);
191
+ console.log(`Config: ${configPath}`);
192
+ return;
193
+ }
194
+
195
+ // Default: list personas
196
+ const data = readPersonas();
197
+ const configPath = getPersonasPath();
198
+ const isDefault = configPath === DEFAULT_PERSONAS_PATH;
199
+ console.log('');
200
+ console.log(`Personas${isDefault ? ' (defaults — no user config yet)' : ''}:`);
201
+ console.log('');
202
+ for (const p of data.personas) {
203
+ console.log(` ${p.id.padEnd(16)} ${p.name.padEnd(14)} ${p.description}`);
204
+ }
205
+ console.log('');
206
+ console.log(`Config: ${configPath}`);
207
+ if (isDefault) {
208
+ console.log('Run "wayfind personas --add" or "wayfind personas --reset" to create a user config.');
209
+ }
210
+ console.log('');
211
+ }
212
+
213
+ // ── Autopilot command ────────────────────────────────────────────────────────
214
+
215
+ function autopilotStatus() {
216
+ const data = readPersonas();
217
+ const profile = readJSONFile(PROFILE_FILE);
218
+ const claimed = (profile && Array.isArray(profile.personas)) ? profile.personas : [];
219
+ const userName = (profile && profile.name) || null;
220
+
221
+ console.log('');
222
+ console.log('Persona Status');
223
+ console.log('\u2500'.repeat(37));
224
+
225
+ for (const persona of data.personas) {
226
+ const isClaimed = claimed.includes(persona.id);
227
+ let status;
228
+ if (isClaimed) {
229
+ status = userName ? `${userName} (you)` : 'You';
230
+ } else if (persona.autopilot) {
231
+ status = 'Autopilot';
232
+ } else {
233
+ status = 'Unfilled';
234
+ }
235
+ console.log(`${persona.name.padEnd(17)}${status}`);
236
+ }
237
+ console.log('');
238
+ }
239
+
240
+ function autopilotEnable(personaId) {
241
+ const configPath = ensureUserConfig();
242
+ const data = readPersonas();
243
+ const persona = data.personas.find((p) => p.id === personaId);
244
+ if (!persona) {
245
+ console.error(`Unknown persona: ${personaId}`);
246
+ console.error(`Available personas: ${data.personas.map((p) => p.id).join(', ')}`);
247
+ process.exit(1);
248
+ }
249
+ if (persona.autopilot) {
250
+ console.log(`Autopilot is already enabled for ${persona.name}.`);
251
+ return;
252
+ }
253
+ persona.autopilot = true;
254
+ writePersonas(configPath, data);
255
+ console.log(`Autopilot enabled for ${persona.name}.`);
256
+ }
257
+
258
+ function autopilotDisable(personaId) {
259
+ const configPath = ensureUserConfig();
260
+ const data = readPersonas();
261
+ const persona = data.personas.find((p) => p.id === personaId);
262
+ if (!persona) {
263
+ console.error(`Unknown persona: ${personaId}`);
264
+ console.error(`Available personas: ${data.personas.map((p) => p.id).join(', ')}`);
265
+ process.exit(1);
266
+ }
267
+ if (!persona.autopilot) {
268
+ console.log(`Autopilot is already disabled for ${persona.name}.`);
269
+ return;
270
+ }
271
+ persona.autopilot = false;
272
+ writePersonas(configPath, data);
273
+ console.log(`Autopilot disabled for ${persona.name}. Persona is now unfilled.`);
274
+ }
275
+
276
+ function runAutopilot(args) {
277
+ const sub = args[0] || 'status';
278
+ if (sub === 'status') {
279
+ autopilotStatus();
280
+ } else if (sub === 'enable') {
281
+ if (!args[1]) {
282
+ console.error('Usage: wayfind autopilot enable <persona-id>');
283
+ process.exit(1);
284
+ }
285
+ autopilotEnable(args[1]);
286
+ } else if (sub === 'disable') {
287
+ if (!args[1]) {
288
+ console.error('Usage: wayfind autopilot disable <persona-id>');
289
+ process.exit(1);
290
+ }
291
+ autopilotDisable(args[1]);
292
+ } else {
293
+ console.error(`Unknown autopilot subcommand: ${sub}`);
294
+ console.error('Usage: wayfind autopilot [status|enable|disable] [persona-id]');
295
+ process.exit(1);
296
+ }
297
+ }
298
+
299
+ // ── JSON file helpers ────────────────────────────────────────────────────────
300
+
301
+ function ensureWayfindDir() {
302
+ if (!fs.existsSync(WAYFIND_DIR)) {
303
+ fs.mkdirSync(WAYFIND_DIR, { recursive: true });
304
+ }
305
+ }
306
+
307
+ function readJSONFile(filePath) {
308
+ try {
309
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
310
+ } catch {
311
+ return null;
312
+ }
313
+ }
314
+
315
+ function writeJSONFile(filePath, data) {
316
+ ensureWayfindDir();
317
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n');
318
+ }
319
+
320
+ function generateTeamId() {
321
+ return crypto.randomBytes(4).toString('hex');
322
+ }
323
+
324
+ function ask(question) {
325
+ const rl = readline.createInterface({
326
+ input: process.stdin,
327
+ output: process.stdout,
328
+ });
329
+ return new Promise((resolve) => {
330
+ rl.question(question, (answer) => {
331
+ rl.close();
332
+ resolve(answer.trim());
333
+ });
334
+ });
335
+ }
336
+
337
+ // ── Team command ────────────────────────────────────────────────────────────
338
+
339
+ async function teamCreate() {
340
+ const name = await ask('Team name: ');
341
+ if (!name) {
342
+ console.error('Error: team name is required.');
343
+ process.exit(1);
344
+ }
345
+
346
+ const id = generateTeamId();
347
+ const personasData = readPersonas();
348
+ const team = {
349
+ name,
350
+ id,
351
+ created: new Date().toISOString(),
352
+ personas: personasData.personas.map((p) => p.id),
353
+ };
354
+
355
+ writeJSONFile(TEAM_FILE, team);
356
+ telemetry.capture('team_created', { member_count: 1 }, CLI_USER);
357
+ console.log('');
358
+ console.log(`Team '${name}' created.`);
359
+ console.log(`Share your team ID with teammates: ${id}`);
360
+ console.log('');
361
+ console.log('Teammates can join with:');
362
+ console.log(` wayfind team join ${id}`);
363
+ console.log('');
364
+ }
365
+
366
+ async function teamJoin(args) {
367
+ const teamId = args[0];
368
+ if (!teamId) {
369
+ console.error('Error: team ID is required.');
370
+ console.error('Usage: wayfind team join <team-id>');
371
+ process.exit(1);
372
+ }
373
+
374
+ const team = {
375
+ teamId,
376
+ joined: new Date().toISOString(),
377
+ };
378
+
379
+ writeJSONFile(TEAM_FILE, team);
380
+ console.log('');
381
+ console.log(`Joined team ${teamId}.`);
382
+
383
+ const profile = readJSONFile(PROFILE_FILE);
384
+ if (profile) {
385
+ syncMemberToRegistry(profile, teamId);
386
+ await announceToSlack(profile, teamId);
387
+ } else {
388
+ console.log(" Run 'wayfind whoami --setup' to register in the team directory.");
389
+ }
390
+ console.log('');
391
+ }
392
+
393
+ function teamStatus() {
394
+ const team = readJSONFile(TEAM_FILE);
395
+ if (!team) {
396
+ console.log('');
397
+ console.log("No team configured. Run 'wayfind team create' or 'wayfind team join <id>'");
398
+ console.log('');
399
+ return;
400
+ }
401
+
402
+ console.log('');
403
+ if (team.name) {
404
+ console.log(`Team: ${team.name}`);
405
+ console.log(`ID: ${team.id}`);
406
+ console.log(`Created: ${team.created}`);
407
+ if (team.personas && team.personas.length > 0) {
408
+ console.log(`Personas: ${team.personas.join(', ')}`);
409
+ }
410
+ } else if (team.teamId) {
411
+ console.log(`Joined team: ${team.teamId}`);
412
+ console.log(`Joined: ${team.joined}`);
413
+ }
414
+
415
+ // Show member roster from team-context repo
416
+ const repoPath = getTeamContextPath();
417
+ if (!repoPath) {
418
+ console.log('');
419
+ console.log("(Run 'wayfind context init' to see team members)");
420
+ } else {
421
+ // Pull latest so we see all members, not just local state
422
+ try {
423
+ const { execSync } = require('child_process');
424
+ execSync(`git -C "${repoPath}" pull --rebase 2>/dev/null`, { stdio: 'pipe' });
425
+ } catch { /* offline is fine — show what we have */ }
426
+ const membersDir = path.join(repoPath, 'members');
427
+ if (!fs.existsSync(membersDir)) {
428
+ console.log('');
429
+ console.log('No members registered yet.');
430
+ } else {
431
+ const files = fs.readdirSync(membersDir).filter((f) => f.endsWith('.json'));
432
+ if (files.length === 0) {
433
+ console.log('');
434
+ console.log('No members registered yet.');
435
+ } else {
436
+ console.log('');
437
+ console.log('Members:');
438
+ for (const file of files) {
439
+ const member = readJSONFile(path.join(membersDir, file));
440
+ if (!member) continue;
441
+ const name = (member.name || '').padEnd(24);
442
+ const personas = Array.isArray(member.personas)
443
+ ? member.personas.join(', ')
444
+ : '';
445
+ const joined = member.joined
446
+ ? member.joined.slice(0, 10)
447
+ : '';
448
+ const slackIndicator = member.slack_user_id ? ' [Slack]' : '';
449
+ console.log(` ${name}${personas.padEnd(24)}joined ${joined}${slackIndicator}`);
450
+ }
451
+ }
452
+ }
453
+ }
454
+ console.log('');
455
+ }
456
+
457
+ async function runTeam(args) {
458
+ const sub = args[0] || 'status';
459
+ const subArgs = args.slice(1);
460
+
461
+ switch (sub) {
462
+ case 'create':
463
+ await teamCreate();
464
+ break;
465
+ case 'join':
466
+ await teamJoin(subArgs);
467
+ break;
468
+ case 'status':
469
+ teamStatus();
470
+ break;
471
+ default:
472
+ console.error(`Unknown team subcommand: ${sub}`);
473
+ console.error('Available: create, join, status');
474
+ process.exit(1);
475
+ }
476
+ }
477
+
478
+ // ── Whoami command ──────────────────────────────────────────────────────────
479
+
480
+ async function whoamiSetup() {
481
+ const name = await ask('Display name: ');
482
+ if (!name) {
483
+ console.error('Error: display name is required.');
484
+ process.exit(1);
485
+ }
486
+
487
+ const personasData = readPersonas();
488
+ console.log('');
489
+ console.log('Available personas:');
490
+ for (const p of personasData.personas) {
491
+ console.log(` ${p.id.padEnd(14)} ${p.description}`);
492
+ }
493
+ console.log('');
494
+
495
+ const selection = await ask('Select personas (comma-separated IDs, e.g. engineering,product): ');
496
+ const selectedIds = selection
497
+ .split(',')
498
+ .map((s) => s.trim())
499
+ .filter(Boolean);
500
+
501
+ const validIds = personasData.personas.map((p) => p.id);
502
+ const invalid = selectedIds.filter((id) => !validIds.includes(id));
503
+ if (invalid.length > 0) {
504
+ console.error(`Unknown persona(s): ${invalid.join(', ')}`);
505
+ console.error(`Valid options: ${validIds.join(', ')}`);
506
+ process.exit(1);
507
+ }
508
+
509
+ if (selectedIds.length === 0) {
510
+ console.error('Error: at least one persona is required.');
511
+ process.exit(1);
512
+ }
513
+
514
+ console.log('');
515
+ console.log('Your Slack user ID lets the bot @mention you in digests and send you direct messages.');
516
+ console.log('To find it: open your Slack profile → click ⋯ → "Copy member ID".');
517
+ const slackUserId = await ask('Slack user ID (or leave blank to skip): ');
518
+
519
+ const profile = {
520
+ name,
521
+ personas: selectedIds,
522
+ created: new Date().toISOString(),
523
+ };
524
+ if (slackUserId) {
525
+ profile.slack_user_id = slackUserId.trim();
526
+ }
527
+
528
+ writeJSONFile(PROFILE_FILE, profile);
529
+ console.log('');
530
+ console.log(`Profile created: ${name}`);
531
+ console.log(`Active personas: ${selectedIds.join(', ')}`);
532
+
533
+ const team = readJSONFile(TEAM_FILE);
534
+ if (team) {
535
+ console.log(`Team: ${team.name || team.teamId}`);
536
+ const teamId = team.id || team.teamId;
537
+ syncMemberToRegistry(profile, teamId);
538
+ await announceToSlack(profile, teamId);
539
+ }
540
+ console.log('');
541
+ }
542
+
543
+ function whoamiShow() {
544
+ const profile = readJSONFile(PROFILE_FILE);
545
+ if (!profile) {
546
+ console.log('');
547
+ console.log("No profile configured. Run 'wayfind whoami --setup' to create one.");
548
+ console.log('');
549
+ return;
550
+ }
551
+
552
+ console.log('');
553
+ console.log(`Name: ${profile.name}`);
554
+ const personas = Array.isArray(profile.personas) ? profile.personas : [];
555
+ console.log(`Personas: ${personas.join(', ')}`);
556
+ console.log(`Created: ${profile.created}`);
557
+ if (profile.slack_user_id) {
558
+ console.log(`Slack user ID: ${profile.slack_user_id}`);
559
+ }
560
+
561
+ const team = readJSONFile(TEAM_FILE);
562
+ if (team) {
563
+ console.log(`Team: ${team.name || team.teamId}`);
564
+ } else {
565
+ console.log('Team: (none)');
566
+ }
567
+ console.log('');
568
+ }
569
+
570
+ async function runWhoami(args) {
571
+ if (args.includes('--setup')) {
572
+ await whoamiSetup();
573
+ } else {
574
+ whoamiShow();
575
+ }
576
+ }
577
+
578
+ // ── Signal channels (pull / signals) ────────────────────────────────────────
579
+
580
+ function readConnectorsConfig() {
581
+ return readJSONFile(CONNECTORS_FILE) || {};
582
+ }
583
+
584
+ function writeConnectorsConfig(config) {
585
+ ensureWayfindDir();
586
+ // Restrict permissions — file may contain API tokens
587
+ fs.writeFileSync(CONNECTORS_FILE, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
588
+ }
589
+
590
+ /**
591
+ * Map connector configs to env var names for the deploy .env.
592
+ * Each entry: { envKey: 'ENV_VAR_NAME', configField: 'field_in_channelConfig' }
593
+ */
594
+ const CONNECTOR_ENV_MAP = {
595
+ intercom: [
596
+ { envKey: 'INTERCOM_TOKEN', configField: 'token' },
597
+ ],
598
+ github: [
599
+ { envKey: 'GITHUB_TOKEN', configField: 'token', configFieldAlt: 'token_env', resolveEnv: true },
600
+ ],
601
+ };
602
+
603
+ /**
604
+ * After configuring a connector locally, sync its secrets to the deploy .env
605
+ * so the container picks them up on restart. No-op if no deploy dir exists.
606
+ */
607
+ function syncConnectorToDeployEnv(channel, channelConfig) {
608
+ const mapping = CONNECTOR_ENV_MAP[channel];
609
+ if (!mapping) return;
610
+
611
+ // Find deploy .env: check team context repo first, then cwd/deploy
612
+ const candidates = [];
613
+ const teamCtxPath = getTeamContextPath();
614
+ if (teamCtxPath) {
615
+ candidates.push(path.join(teamCtxPath, 'deploy', '.env'));
616
+ }
617
+ candidates.push(path.join(process.cwd(), 'deploy', '.env'));
618
+
619
+ let envFile = null;
620
+ for (const candidate of candidates) {
621
+ if (fs.existsSync(candidate)) {
622
+ envFile = candidate;
623
+ break;
624
+ }
625
+ }
626
+ if (!envFile) return;
627
+
628
+ let envContent = fs.readFileSync(envFile, 'utf8');
629
+ let updated = false;
630
+
631
+ for (const { envKey, configField, configFieldAlt, resolveEnv } of mapping) {
632
+ let value = channelConfig[configField] || '';
633
+ if (!value && resolveEnv && configFieldAlt && channelConfig[configFieldAlt]) {
634
+ value = process.env[channelConfig[configFieldAlt]] || '';
635
+ }
636
+ if (!value) continue;
637
+
638
+ const lines = envContent.split('\n');
639
+ const idx = lines.findIndex((l) => l.startsWith(`${envKey}=`));
640
+ if (idx !== -1) {
641
+ lines[idx] = `${envKey}=${value}`;
642
+ } else {
643
+ lines.push(`${envKey}=${value}`);
644
+ }
645
+ envContent = lines.join('\n');
646
+ updated = true;
647
+ }
648
+
649
+ if (updated) {
650
+ fs.writeFileSync(envFile, envContent, { mode: 0o600 });
651
+ console.log(`Deploy .env updated: ${envFile}`);
652
+ }
653
+ }
654
+
655
+ function getSinceDate(args) {
656
+ const sinceIdx = args.indexOf('--since');
657
+ if (sinceIdx !== -1 && args[sinceIdx + 1]) {
658
+ const val = args[sinceIdx + 1];
659
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(val)) {
660
+ console.error(`Invalid date format: "${val}". Expected YYYY-MM-DD.`);
661
+ process.exit(1);
662
+ }
663
+ return val;
664
+ }
665
+ // Default: yesterday
666
+ const d = new Date();
667
+ d.setDate(d.getDate() - 1);
668
+ return d.toISOString().split('T')[0];
669
+ }
670
+
671
+ function printPullResult(channel, result) {
672
+ console.log('');
673
+ if (channel === 'intercom') {
674
+ console.log(` ${channel}:`);
675
+ console.log(` Conversations: ${result.counts.conversations}`);
676
+ console.log(` Open: ${result.counts.open}`);
677
+ console.log(` Tags: ${result.counts.tags}`);
678
+ } else if (channel === 'notion') {
679
+ console.log(` ${channel}:`);
680
+ console.log(` Pages: ${result.counts.pages}`);
681
+ console.log(` Database entries: ${result.counts.database_entries}`);
682
+ console.log(` Comments: ${result.counts.comments}`);
683
+ } else {
684
+ console.log(` ${channel}: ${result.counts.repos} repo(s)`);
685
+ console.log(` Issues: ${result.counts.issues}`);
686
+ console.log(` PRs: ${result.counts.prs}`);
687
+ console.log(` CI runs: ${result.counts.runs}`);
688
+ }
689
+ console.log('');
690
+ console.log(' Files written:');
691
+ for (const f of result.files) {
692
+ console.log(` ${f}`);
693
+ }
694
+ console.log('');
695
+ }
696
+
697
+ async function runPull(args) {
698
+ // --all: pull all configured channels
699
+ if (args.includes('--all')) {
700
+ const config = readConnectorsConfig();
701
+ const channels = Object.keys(config).filter((k) => connectors.get(k));
702
+ if (channels.length === 0) {
703
+ console.log('No channels configured. Run "wayfind pull <channel> --configure" first.');
704
+ return;
705
+ }
706
+ const since = getSinceDate(args);
707
+ for (const name of channels) {
708
+ const connector = connectors.get(name);
709
+ if (!connector) {
710
+ console.log(`Warning: unknown connector "${name}", skipping.`);
711
+ continue;
712
+ }
713
+ console.log(`\nPulling ${name}...`);
714
+ const result = await connector.pull(config[name], since);
715
+ // Update last_pull — re-read config fresh to avoid stale overwrites
716
+ const freshConfig = readConnectorsConfig();
717
+ freshConfig[name].last_pull = new Date().toISOString();
718
+ writeConnectorsConfig(freshConfig);
719
+ printPullResult(name, result);
720
+ }
721
+ return;
722
+ }
723
+
724
+ const channel = args[0];
725
+ if (!channel) {
726
+ console.error('Usage: wayfind pull <channel> [--since YYYY-MM-DD] [--configure]');
727
+ console.error(' wayfind pull --all');
728
+ console.error(`Available channels: ${connectors.list().join(', ')}`);
729
+ process.exit(1);
730
+ }
731
+
732
+ const connector = connectors.get(channel);
733
+ if (!connector) {
734
+ console.error(`Unknown channel: ${channel}`);
735
+ console.error(`Available channels: ${connectors.list().join(', ')}`);
736
+ process.exit(1);
737
+ }
738
+
739
+ const channelArgs = args.slice(1);
740
+
741
+ // --configure
742
+ if (channelArgs.includes('--configure')) {
743
+ const channelConfig = await connector.configure();
744
+ const config = readConnectorsConfig();
745
+ config[channel] = channelConfig;
746
+ writeConnectorsConfig(config);
747
+ syncConnectorToDeployEnv(channel, channelConfig);
748
+ console.log(`\n${channel} configured successfully.`);
749
+ return;
750
+ }
751
+
752
+ // --add-repo
753
+ const addIdx = channelArgs.indexOf('--add-repo');
754
+ if (addIdx !== -1) {
755
+ const repoArg = channelArgs[addIdx + 1];
756
+ if (!repoArg || !/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(repoArg)) {
757
+ console.error(`Usage: wayfind pull ${channel} --add-repo owner/repo`);
758
+ process.exit(1);
759
+ }
760
+ const [owner, repo] = repoArg.split('/');
761
+ const config = readConnectorsConfig();
762
+ if (!config[channel]) {
763
+ console.error(`${channel} is not configured. Run "wayfind pull ${channel} --configure" first.`);
764
+ process.exit(1);
765
+ }
766
+ config[channel].repos = config[channel].repos || [];
767
+ const exists = config[channel].repos.some(r => {
768
+ if (typeof r === 'string') return r === repoArg;
769
+ return r.owner === owner && (r.repo === repo || r.name === repo);
770
+ });
771
+ if (exists) {
772
+ console.log(`${owner}/${repo} is already configured.`);
773
+ return;
774
+ }
775
+ config[channel].repos.push({ owner, repo });
776
+ writeConnectorsConfig(config);
777
+ console.log(`Added ${owner}/${repo} to ${channel}.`);
778
+ return;
779
+ }
780
+
781
+ // --remove-repo
782
+ const removeIdx = channelArgs.indexOf('--remove-repo');
783
+ if (removeIdx !== -1) {
784
+ const repoArg = channelArgs[removeIdx + 1];
785
+ if (!repoArg || !/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(repoArg)) {
786
+ console.error(`Usage: wayfind pull ${channel} --remove-repo owner/repo`);
787
+ process.exit(1);
788
+ }
789
+ const [owner, repo] = repoArg.split('/');
790
+ const config = readConnectorsConfig();
791
+ if (!config[channel] || !config[channel].repos) {
792
+ console.error(`${channel} is not configured.`);
793
+ process.exit(1);
794
+ }
795
+ const before = config[channel].repos.length;
796
+ config[channel].repos = config[channel].repos.filter(r => {
797
+ if (typeof r === 'string') return r !== repoArg;
798
+ return !(r.owner === owner && (r.repo === repo || r.name === repo));
799
+ });
800
+ if (config[channel].repos.length === before) {
801
+ console.error(`${owner}/${repo} not found in ${channel} config.`);
802
+ process.exit(1);
803
+ }
804
+ writeConnectorsConfig(config);
805
+ console.log(`Removed ${owner}/${repo} from ${channel}.`);
806
+ return;
807
+ }
808
+
809
+ // Default: pull
810
+ let config = readConnectorsConfig();
811
+ if (!config[channel]) {
812
+ console.log(`${channel} is not configured. Starting configuration...`);
813
+ console.log('');
814
+ const channelConfig = await connector.configure();
815
+ config[channel] = channelConfig;
816
+ writeConnectorsConfig(config);
817
+ config = readConnectorsConfig();
818
+ }
819
+
820
+ const since = getSinceDate(channelArgs);
821
+ console.log(`Pulling ${channel} signals since ${since}...`);
822
+ const result = await connector.pull(config[channel], since);
823
+
824
+ // Update last_pull — re-read fresh to avoid stale overwrites
825
+ const freshConfig = readConnectorsConfig();
826
+ freshConfig[channel].last_pull = new Date().toISOString();
827
+ writeConnectorsConfig(freshConfig);
828
+
829
+ printPullResult(channel, result);
830
+ }
831
+
832
+ function runSignals() {
833
+ const config = readConnectorsConfig();
834
+ const channels = Object.keys(config);
835
+
836
+ console.log('');
837
+ if (channels.length === 0) {
838
+ console.log('No signal channels configured.');
839
+ console.log('');
840
+ console.log('Available channels:');
841
+ for (const name of connectors.list()) {
842
+ console.log(` ${name}`);
843
+ }
844
+ console.log('');
845
+ console.log('Configure a channel:');
846
+ console.log(' wayfind pull <channel> --configure');
847
+ console.log('');
848
+ return;
849
+ }
850
+
851
+ console.log('Signal Channels:');
852
+ console.log('');
853
+ for (const name of channels) {
854
+ const ch = config[name];
855
+ const lastPull = ch.last_pull ? new Date(ch.last_pull).toLocaleString() : 'never';
856
+ const transport = ch.transport || 'unknown';
857
+ if (ch.repos) {
858
+ const repoCount = ch.repos.length || 0;
859
+ console.log(` ${name.padEnd(12)} ${repoCount} repo(s) transport: ${transport} last pull: ${lastPull}`);
860
+ } else {
861
+ const tagInfo = ch.tag_filter ? `tags: ${ch.tag_filter.join(', ')}` : 'all conversations';
862
+ console.log(` ${name.padEnd(12)} ${tagInfo} transport: ${transport} last pull: ${lastPull}`);
863
+ }
864
+ }
865
+ console.log('');
866
+
867
+ // Show unconfigured available channels
868
+ const available = connectors.list().filter(n => !channels.includes(n));
869
+ if (available.length > 0) {
870
+ console.log('Available (not configured):');
871
+ for (const name of available) {
872
+ console.log(` ${name}`);
873
+ }
874
+ console.log('');
875
+ }
876
+ }
877
+
878
+ // ── Digest command ──────────────────────────────────────────────────────────
879
+
880
+ async function runDigest(args) {
881
+ // --configure
882
+ if (args.includes('--configure')) {
883
+ const digestConfig = await digest.configure();
884
+ const config = readConnectorsConfig();
885
+ config.digest = digestConfig;
886
+ writeConnectorsConfig(config);
887
+ syncWebhookToTeamContext(digestConfig);
888
+ console.log('\nDigest configured successfully.');
889
+ return;
890
+ }
891
+
892
+ // scores subcommand
893
+ if (args.includes('scores') || args.includes('--scores')) {
894
+ const config = readConnectorsConfig();
895
+ const storePath = (config.digest && config.digest.store_path) || undefined;
896
+ const feedback = contentStore.getDigestFeedback({ storePath, limit: 20 });
897
+ if (feedback.length === 0) {
898
+ console.log('No digest feedback yet. Reactions on digest messages will appear here.');
899
+ return;
900
+ }
901
+ console.log('Digest Feedback\n');
902
+ for (const d of feedback) {
903
+ const reactions = Object.entries(d.reactions)
904
+ .map(([emoji, count]) => `:${emoji}: \u00d7 ${count}`)
905
+ .join(' ');
906
+ console.log(` ${d.date} (${d.persona}): ${reactions || 'no reactions'} \u2014 total: ${d.totalReactions}`);
907
+ if (d.comments.length > 0) {
908
+ for (const c of d.comments) {
909
+ console.log(` \u2192 "${c.text}"`);
910
+ }
911
+ }
912
+ }
913
+ return;
914
+ }
915
+
916
+ // Read digest config
917
+ const config = readConnectorsConfig();
918
+ if (!config.digest) {
919
+ console.log('Digest is not configured. Starting configuration...');
920
+ console.log('');
921
+ const digestConfig = await digest.configure();
922
+ config.digest = digestConfig;
923
+ writeConnectorsConfig(config);
924
+ syncWebhookToTeamContext(digestConfig);
925
+ }
926
+
927
+ // Parse flags
928
+ const personaIdx = args.indexOf('--persona');
929
+ const sinceIdx = args.indexOf('--since');
930
+ const deliver = args.includes('--deliver');
931
+
932
+ // Determine personas
933
+ let personaIds;
934
+ if (personaIdx !== -1 && args[personaIdx + 1]) {
935
+ const val = args[personaIdx + 1];
936
+ if (val.startsWith('--')) {
937
+ console.error(`Invalid persona: "${val}". Did you forget the persona name after --persona?`);
938
+ process.exit(1);
939
+ }
940
+ personaIds = [val];
941
+ } else {
942
+ personaIds = (config.digest.slack && config.digest.slack.default_personas)
943
+ || ['unified'];
944
+ }
945
+
946
+ // Determine since date
947
+ let sinceDate;
948
+ if (sinceIdx !== -1 && args[sinceIdx + 1]) {
949
+ sinceDate = args[sinceIdx + 1];
950
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(sinceDate)) {
951
+ console.error(`Invalid date format: "${sinceDate}". Expected YYYY-MM-DD.`);
952
+ process.exit(1);
953
+ }
954
+ } else {
955
+ const d = new Date();
956
+ d.setDate(d.getDate() - (config.digest.lookback_days || 7));
957
+ sinceDate = d.toISOString().split('T')[0];
958
+ }
959
+
960
+ // Check API key is available before generating (skip in simulation mode)
961
+ const apiKeyEnv = config.digest.llm && config.digest.llm.api_key_env;
962
+ if (apiKeyEnv && !process.env[apiKeyEnv] && process.env.TEAM_CONTEXT_SIMULATE !== '1') {
963
+ console.error(`Error: ${apiKeyEnv} is not set.`);
964
+ console.error('');
965
+ console.error('Fix: run "wayfind digest --configure" to save your API key,');
966
+ console.error(`or set ${apiKeyEnv} in your shell environment.`);
967
+ process.exit(1);
968
+ }
969
+
970
+ // Generate digests
971
+ console.log(`Generating digests for: ${personaIds.join(', ')}`);
972
+ console.log(`Period: ${sinceDate} to today`);
973
+ console.log('');
974
+
975
+ const result = await digest.generateDigest(config.digest, personaIds, sinceDate, (progress) => {
976
+ if (progress.phase === 'start') {
977
+ process.stdout.write(` ${progress.personaId} (${progress.index + 1}/${progress.total})... `);
978
+ } else if (progress.phase === 'done') {
979
+ process.stdout.write(`done (${progress.elapsed}s)\n`);
980
+ }
981
+ });
982
+
983
+ console.log('');
984
+ console.log('Digests generated:');
985
+ for (const f of result.files) {
986
+ console.log(` ${f}`);
987
+ }
988
+ console.log('');
989
+
990
+ // Update quality profile (piggyback on digest generation)
991
+ try {
992
+ const qualityProfile = contentStore.computeQualityProfile({ days: 30 });
993
+ if (qualityProfile.totalDecisions > 0) {
994
+ const existingProfile = readJSONFile(PROFILE_FILE) || {};
995
+ existingProfile.quality_profile = {
996
+ computed_at: new Date().toISOString(),
997
+ days: 30,
998
+ total_decisions: qualityProfile.totalDecisions,
999
+ rich_rate: qualityProfile.richRate,
1000
+ reasoning_rate: qualityProfile.reasoning.rate,
1001
+ alternatives_rate: qualityProfile.alternatives.rate,
1002
+ focus: qualityProfile.focus,
1003
+ };
1004
+ writeJSONFile(PROFILE_FILE, existingProfile);
1005
+ }
1006
+ } catch {
1007
+ // Non-fatal — quality profile update failure shouldn't block digest
1008
+ }
1009
+
1010
+ // Deliver to Slack
1011
+ if (deliver) {
1012
+ const webhookUrl = process.env.TEAM_CONTEXT_SLACK_WEBHOOK
1013
+ || (config.digest.slack && config.digest.slack.webhook_url);
1014
+
1015
+ if (!webhookUrl) {
1016
+ console.error('No Slack webhook configured.');
1017
+ console.error('Set TEAM_CONTEXT_SLACK_WEBHOOK env var or run "wayfind digest --configure".');
1018
+ process.exit(1);
1019
+ }
1020
+
1021
+ console.log('Delivering to Slack...');
1022
+ const deliveryResults = await slack.deliverAll(webhookUrl, result, personaIds, {
1023
+ botToken: process.env.SLACK_BOT_TOKEN,
1024
+ channel: process.env.SLACK_DIGEST_CHANNEL,
1025
+ });
1026
+ let failures = 0;
1027
+ const dateStr = result.dateRange.to;
1028
+ for (const r of deliveryResults) {
1029
+ if (r.ok) {
1030
+ console.log(` ${r.persona}: delivered`);
1031
+ telemetry.capture('digest_delivered', { persona: r.persona, channel: r.channel ? 'set' : 'unset' }, CLI_USER);
1032
+ if (r.ts) {
1033
+ contentStore.recordDigestDelivery({
1034
+ date: dateStr,
1035
+ persona: r.persona,
1036
+ channel: r.channel,
1037
+ ts: r.ts,
1038
+ storePath: (config.digest && config.digest.store_path) || undefined,
1039
+ });
1040
+ }
1041
+ } else {
1042
+ console.error(` ${r.persona}: FAILED - ${r.error}`);
1043
+ failures++;
1044
+ }
1045
+ }
1046
+ console.log('');
1047
+ if (failures > 0) {
1048
+ console.error(`${failures} of ${deliveryResults.length} deliveries failed.`);
1049
+ process.exit(1);
1050
+ }
1051
+ }
1052
+ }
1053
+
1054
+ // ── Content store commands ──────────────────────────────────────────────────
1055
+
1056
+ // Flags that take a value (consume the next arg)
1057
+ const CS_VALUE_FLAGS = new Set(['--dir', '--store', '--limit', '--repo', '--since', '--until']);
1058
+
1059
+ function parseCSArgs(args) {
1060
+ const opts = {};
1061
+ const positional = [];
1062
+ for (let i = 0; i < args.length; i++) {
1063
+ const arg = args[i];
1064
+ if (CS_VALUE_FLAGS.has(arg) && i + 1 < args.length) {
1065
+ opts[arg.replace(/^--/, '')] = args[++i];
1066
+ } else if (arg === '--text') {
1067
+ opts.text = true;
1068
+ } else if (arg === '--json') {
1069
+ opts.json = true;
1070
+ } else if (arg === '--drifted') {
1071
+ opts.drifted = true;
1072
+ } else if (arg === '--no-embeddings') {
1073
+ opts.noEmbeddings = true;
1074
+ } else if (!arg.startsWith('--')) {
1075
+ positional.push(arg);
1076
+ }
1077
+ }
1078
+ return { opts, positional };
1079
+ }
1080
+
1081
+ async function runIndexJournals(args) {
1082
+ const { opts } = parseCSArgs(args);
1083
+ const journalDir = opts.dir || contentStore.DEFAULT_JOURNAL_DIR;
1084
+ const storePath = opts.store || contentStore.DEFAULT_STORE_PATH;
1085
+
1086
+ console.log(`Indexing journals from: ${journalDir}`);
1087
+ console.log(`Store: ${storePath}`);
1088
+ console.log('');
1089
+
1090
+ try {
1091
+ const stats = await contentStore.indexJournals({
1092
+ journalDir,
1093
+ storePath,
1094
+ embeddings: opts.noEmbeddings ? false : undefined,
1095
+ });
1096
+
1097
+ console.log(`Indexed: ${stats.entryCount} entries`);
1098
+ console.log(` New: ${stats.newEntries}`);
1099
+ console.log(` Updated: ${stats.updatedEntries}`);
1100
+ console.log(` Unchanged: ${stats.skippedEntries}`);
1101
+ console.log(` Removed: ${stats.removedEntries}`);
1102
+ console.log('');
1103
+ } catch (err) {
1104
+ console.error(`Error: ${err.message}`);
1105
+ process.exit(1);
1106
+ }
1107
+ }
1108
+
1109
+ async function runIndexConversations(args) {
1110
+ const { opts } = parseCSArgs(args);
1111
+ const projectsDir = opts.dir || contentStore.DEFAULT_PROJECTS_DIR;
1112
+ const storePath = opts.store || contentStore.DEFAULT_STORE_PATH;
1113
+
1114
+ console.log(`Indexing conversations from: ${projectsDir}`);
1115
+ console.log(`Store: ${storePath}`);
1116
+ console.log('');
1117
+
1118
+ try {
1119
+ const stats = await contentStore.indexConversations({
1120
+ projectsDir,
1121
+ storePath,
1122
+ embeddings: opts.noEmbeddings ? false : undefined,
1123
+ since: opts.since,
1124
+ onProgress: (p) => {
1125
+ if (p.phase === 'extracting') {
1126
+ console.log(` Extracting: ${p.repo} ...`);
1127
+ }
1128
+ },
1129
+ });
1130
+
1131
+ console.log('');
1132
+ console.log(`Scanned: ${stats.transcriptsScanned} transcripts`);
1133
+ console.log(`Processed: ${stats.transcriptsProcessed}`);
1134
+ console.log(`Decisions: ${stats.decisionsExtracted}`);
1135
+ console.log(`Skipped: ${stats.skipped}`);
1136
+ if (stats.errors > 0) {
1137
+ console.log(`Errors: ${stats.errors}`);
1138
+ }
1139
+ console.log('');
1140
+ } catch (err) {
1141
+ console.error(`Error: ${err.message}`);
1142
+ process.exit(1);
1143
+ }
1144
+ }
1145
+
1146
+ async function runReindex(args) {
1147
+ const { opts } = parseCSArgs(args);
1148
+ const journalsOnly = args.includes('--journals-only');
1149
+ const conversationsOnly = args.includes('--conversations-only');
1150
+ const signalsOnly = args.includes('--signals-only');
1151
+ const doExport = args.includes('--export');
1152
+ const detectShifts = args.includes('--detect-shifts');
1153
+
1154
+ if (!conversationsOnly && !signalsOnly) {
1155
+ console.log('=== Journals ===');
1156
+ await runIndexJournals(args);
1157
+ }
1158
+
1159
+ if (!journalsOnly && !signalsOnly) {
1160
+ if (doExport) {
1161
+ console.log('=== Conversations (with journal export) ===');
1162
+ await runIndexConversationsWithExport(args, detectShifts);
1163
+ } else {
1164
+ console.log('=== Conversations ===');
1165
+ await runIndexConversations(args);
1166
+ }
1167
+ }
1168
+
1169
+ if (!journalsOnly && !conversationsOnly) {
1170
+ console.log('=== Signals ===');
1171
+ await indexSignalsIfAvailable();
1172
+ }
1173
+ }
1174
+
1175
+ /**
1176
+ * Build a repo-to-team resolver function.
1177
+ * Scans context.json teams and all known repo bindings to map repo names → team IDs.
1178
+ * Falls back to default team if no binding is found.
1179
+ * @returns {function(string): string|null} - Maps repo name (e.g. "acme-corp/api") to team ID
1180
+ */
1181
+ function buildRepoToTeamResolver() {
1182
+ const config = readContextConfig();
1183
+ if (!config.teams) return () => null;
1184
+
1185
+ // Build a lookup: scan all known repo paths for .claude/wayfind.json bindings
1186
+ const repoToTeamMap = {};
1187
+
1188
+ // Check common repo roots for bindings
1189
+ const envRoots = process.env.AI_MEMORY_SCAN_ROOTS;
1190
+ const roots = envRoots
1191
+ ? envRoots.split(':').filter(Boolean)
1192
+ : [path.join(HOME, 'repos')];
1193
+
1194
+ for (const root of roots) {
1195
+ if (!fs.existsSync(root)) continue;
1196
+ try {
1197
+ // Two levels deep: root/org/repo
1198
+ const orgs = fs.readdirSync(root).filter(d => {
1199
+ try { return fs.statSync(path.join(root, d)).isDirectory(); } catch { return false; }
1200
+ });
1201
+ for (const org of orgs) {
1202
+ const orgDir = path.join(root, org);
1203
+ let repos;
1204
+ try { repos = fs.readdirSync(orgDir); } catch { continue; }
1205
+ for (const repo of repos) {
1206
+ const bindingFile = path.join(orgDir, repo, '.claude', 'wayfind.json');
1207
+ try {
1208
+ const binding = JSON.parse(fs.readFileSync(bindingFile, 'utf8'));
1209
+ if (binding.team_id) {
1210
+ repoToTeamMap[`${org}/${repo}`] = binding.team_id;
1211
+ }
1212
+ } catch {
1213
+ // No binding file — skip
1214
+ }
1215
+ }
1216
+ }
1217
+ } catch {
1218
+ // Skip unreadable roots
1219
+ }
1220
+ }
1221
+
1222
+ return (repoName) => {
1223
+ // Direct match
1224
+ if (repoToTeamMap[repoName]) return repoToTeamMap[repoName];
1225
+
1226
+ // Try partial match (repo name might be "Org/Repo/SubDir" or just "Repo")
1227
+ for (const [key, teamId] of Object.entries(repoToTeamMap)) {
1228
+ if (repoName.startsWith(key + '/') || repoName === key) return teamId;
1229
+ }
1230
+
1231
+ // Fall back to default team
1232
+ return config.default || null;
1233
+ };
1234
+ }
1235
+
1236
+ async function runIndexConversationsWithExport(args, detectShifts = false) {
1237
+ const { opts } = parseCSArgs(args);
1238
+ const projectsDir = opts.dir || contentStore.DEFAULT_PROJECTS_DIR;
1239
+ const storePath = opts.store || contentStore.DEFAULT_STORE_PATH;
1240
+ const journalDir = opts.exportDir || contentStore.DEFAULT_JOURNAL_DIR;
1241
+
1242
+ console.log(`Indexing conversations from: ${projectsDir}`);
1243
+ console.log(`Exporting decisions to: ${journalDir}`);
1244
+ console.log('');
1245
+
1246
+ // Build repo→team resolver for per-team journal routing
1247
+ const repoToTeam = buildRepoToTeamResolver();
1248
+
1249
+ try {
1250
+ const stats = await contentStore.indexConversationsWithExport({
1251
+ projectsDir,
1252
+ storePath,
1253
+ exportDir: journalDir,
1254
+ repoToTeam,
1255
+ author: getAuthorSlug(),
1256
+ embeddings: opts.noEmbeddings ? false : undefined,
1257
+ since: opts.since,
1258
+ onProgress: (p) => {
1259
+ if (p.phase === 'extracting') {
1260
+ console.log(` Extracting: ${p.repo} ...`);
1261
+ }
1262
+ },
1263
+ });
1264
+
1265
+ console.log('');
1266
+ console.log(`Scanned: ${stats.transcriptsScanned} transcripts`);
1267
+ console.log(`Processed: ${stats.transcriptsProcessed}`);
1268
+ const rich = stats.richCount || 0;
1269
+ const thin = stats.thinCount || 0;
1270
+ const qualitySuffix = (rich + thin) > 0 ? ` (${rich} rich, ${thin} thin)` : '';
1271
+ console.log(`Decisions: ${stats.decisionsExtracted}${qualitySuffix}`);
1272
+ console.log(`Exported: ${stats.exported}`);
1273
+ console.log(`Skipped: ${stats.skipped}`);
1274
+ if (stats.errors > 0) {
1275
+ console.log(`Errors: ${stats.errors}`);
1276
+ }
1277
+
1278
+ // Write session stats JSON for status line display
1279
+ if (args.includes('--write-stats')) {
1280
+ const statsData = {
1281
+ decisions: stats.decisionsExtracted || 0,
1282
+ exported: stats.exported || 0,
1283
+ rich: rich,
1284
+ thin: thin,
1285
+ session_date: new Date().toISOString().slice(0, 10),
1286
+ timestamp: new Date().toISOString(),
1287
+ };
1288
+ const statsPath = path.join(HOME, '.claude', 'team-context', 'session-stats.json');
1289
+ try {
1290
+ fs.mkdirSync(path.dirname(statsPath), { recursive: true });
1291
+ fs.writeFileSync(statsPath, JSON.stringify(statsData, null, 2) + '\n');
1292
+ } catch (e) {
1293
+ // Non-fatal — status line just won't update
1294
+ }
1295
+
1296
+ // Telemetry: decision quality per session
1297
+ if (stats.exported > 0) {
1298
+ telemetry.capture('decision_quality', {
1299
+ decisions: stats.decisionsExtracted || 0,
1300
+ exported: stats.exported || 0,
1301
+ rich: rich,
1302
+ thin: thin,
1303
+ rich_rate: (rich + thin) > 0 ? Math.round((rich / (rich + thin)) * 100) : 0,
1304
+ }, CLI_USER);
1305
+ }
1306
+ }
1307
+
1308
+ // Context shift detection — single classification per reindex run
1309
+ if (detectShifts && stats.pendingExports && stats.pendingExports.length > 0) {
1310
+ console.log('');
1311
+ console.log('=== Context Shift Detection ===');
1312
+
1313
+ // Aggregate all decisions into one batch for a single LLM call
1314
+ const aggregated = [{
1315
+ date: stats.pendingExports[0].date,
1316
+ repo: stats.pendingExports.map(e => e.repo).filter((v, i, a) => a.indexOf(v) === i).join(', '),
1317
+ decisions: stats.pendingExports.flatMap(e => e.decisions),
1318
+ }];
1319
+
1320
+ // Read current state for context
1321
+ const repoDir = process.cwd();
1322
+ const claudeDir = path.join(repoDir, '.claude');
1323
+ let stateContext = '';
1324
+ for (const f of ['team-state.md', 'personal-state.md', 'state.md']) {
1325
+ const p = path.join(claudeDir, f);
1326
+ if (fs.existsSync(p)) {
1327
+ const content = fs.readFileSync(p, 'utf8');
1328
+ stateContext += `--- ${f} ---\n${content.slice(0, 2000)}\n\n`;
1329
+ }
1330
+ }
1331
+
1332
+ const llmConfig = {
1333
+ provider: 'anthropic',
1334
+ model: process.env.TEAM_CONTEXT_SHIFT_MODEL || 'claude-haiku-4-5-20251001',
1335
+ api_key_env: 'ANTHROPIC_API_KEY',
1336
+ };
1337
+
1338
+ const shift = await contentStore.detectContextShift(
1339
+ aggregated, llmConfig, stateContext
1340
+ );
1341
+
1342
+ if (shift.hasShift) {
1343
+ console.log(`Shift detected: ${shift.summary}`);
1344
+ const applied = contentStore.applyContextShiftToState(
1345
+ shift.stateUpdates, repoDir, shift.summary
1346
+ );
1347
+ if (applied.teamUpdated) console.log(' Updated: team-state.md');
1348
+ if (applied.personalUpdated) console.log(' Updated: personal-state.md');
1349
+ if (!applied.teamUpdated && !applied.personalUpdated) {
1350
+ console.log(' No state files found to update (or shift already recorded today).');
1351
+ }
1352
+ } else {
1353
+ console.log('No significant context shift detected.');
1354
+ }
1355
+ }
1356
+
1357
+ console.log('');
1358
+ } catch (err) {
1359
+ console.error(`Error: ${err.message}`);
1360
+ process.exit(1);
1361
+ }
1362
+ }
1363
+
1364
+ async function runOnboard(args) {
1365
+ const { opts, positional } = parseCSArgs(args);
1366
+ const repoQuery = positional.join(' ');
1367
+
1368
+ if (!repoQuery) {
1369
+ console.error('Usage: wayfind onboard <repo-name> [--days N] [--output <path>]');
1370
+ console.error(' e.g. wayfind onboard SellingService');
1371
+ console.error(' e.g. wayfind onboard acme-corp/web-api --days 30');
1372
+ process.exit(1);
1373
+ }
1374
+
1375
+ const days = opts.days ? parseInt(opts.days, 10) : 90;
1376
+ const outputPath = opts.output;
1377
+
1378
+ console.error(`Generating onboarding pack for "${repoQuery}" (last ${days} days)...`);
1379
+ console.error('');
1380
+
1381
+ try {
1382
+ const pack = await contentStore.generateOnboardingPack(repoQuery, {
1383
+ storePath: opts.store || undefined,
1384
+ days,
1385
+ });
1386
+
1387
+ if (outputPath) {
1388
+ fs.writeFileSync(outputPath, pack + '\n');
1389
+ console.error(`Written to: ${outputPath}`);
1390
+ } else {
1391
+ console.log(pack);
1392
+ }
1393
+ } catch (err) {
1394
+ console.error(`Error: ${err.message}`);
1395
+ process.exit(1);
1396
+ }
1397
+ }
1398
+
1399
+ async function runSearchJournals(args) {
1400
+ const { opts, positional } = parseCSArgs(args);
1401
+ const query = positional.join(' ');
1402
+
1403
+ if (!query) {
1404
+ console.error('Usage: wayfind search-journals <query> [--text] [--limit N] [--repo <name>] [--since YYYY-MM-DD] [--until YYYY-MM-DD] [--drifted]');
1405
+ process.exit(1);
1406
+ }
1407
+
1408
+ const searchOpts = {
1409
+ storePath: opts.store || undefined,
1410
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
1411
+ repo: opts.repo,
1412
+ since: opts.since,
1413
+ until: opts.until,
1414
+ drifted: opts.drifted || undefined,
1415
+ };
1416
+
1417
+ try {
1418
+ let results;
1419
+ if (opts.text) {
1420
+ results = contentStore.searchText(query, searchOpts);
1421
+ } else {
1422
+ results = await contentStore.searchJournals(query, searchOpts);
1423
+ }
1424
+
1425
+ if (results.length === 0) {
1426
+ console.log('No results found.');
1427
+ return;
1428
+ }
1429
+
1430
+ console.log(`Found ${results.length} result(s):`);
1431
+ console.log('');
1432
+ for (const r of results) {
1433
+ const drift = r.entry.drifted ? ' [DRIFT]' : '';
1434
+ console.log(` ${r.entry.date} ${r.entry.repo} — ${r.entry.title}${drift}`);
1435
+ console.log(` score: ${r.score} tags: ${(r.entry.tags || []).join(', ')}`);
1436
+ }
1437
+ console.log('');
1438
+ } catch (err) {
1439
+ console.error(`Error: ${err.message}`);
1440
+ process.exit(1);
1441
+ }
1442
+ }
1443
+
1444
+ function runInsights(args) {
1445
+ const { opts } = parseCSArgs(args);
1446
+ const insights = contentStore.extractInsights({
1447
+ storePath: opts.store || undefined,
1448
+ });
1449
+
1450
+ if (opts.json) {
1451
+ console.log(JSON.stringify(insights, null, 2));
1452
+ return;
1453
+ }
1454
+
1455
+ console.log('');
1456
+ console.log('Journal Insights');
1457
+ console.log('================');
1458
+ console.log('');
1459
+ console.log(`Total sessions: ${insights.totalSessions}`);
1460
+ console.log(`Drift rate: ${insights.driftRate}%`);
1461
+ console.log('');
1462
+
1463
+ if (Object.keys(insights.repoActivity).length > 0) {
1464
+ console.log('Repo activity:');
1465
+ const sorted = Object.entries(insights.repoActivity).sort((a, b) => b[1] - a[1]);
1466
+ for (const [repo, count] of sorted) {
1467
+ console.log(` ${repo.padEnd(30)} ${count} session(s)`);
1468
+ }
1469
+ console.log('');
1470
+ }
1471
+
1472
+ if (Object.keys(insights.tagFrequency).length > 0) {
1473
+ console.log('Top tags:');
1474
+ const sorted = Object.entries(insights.tagFrequency).sort((a, b) => b[1] - a[1]).slice(0, 15);
1475
+ for (const [tag, count] of sorted) {
1476
+ console.log(` ${tag.padEnd(20)} ${count}`);
1477
+ }
1478
+ console.log('');
1479
+ }
1480
+
1481
+ if (insights.quality && insights.quality.totalDecisions > 0) {
1482
+ const q = insights.quality;
1483
+ console.log('Decision quality:');
1484
+ console.log(` Total decisions: ${q.totalDecisions}`);
1485
+ console.log(` Rich: ${q.rich} (${q.richRate}%)`);
1486
+ console.log(` Thin: ${q.thin}`);
1487
+ console.log('');
1488
+ }
1489
+
1490
+ if (insights.timeline.length > 0) {
1491
+ console.log('Timeline (last 14 days):');
1492
+ const recent = insights.timeline.slice(-14);
1493
+ for (const { date, sessions } of recent) {
1494
+ const bar = '\u2588'.repeat(Math.min(sessions, 40));
1495
+ console.log(` ${date} ${bar} ${sessions}`);
1496
+ }
1497
+ console.log('');
1498
+ }
1499
+ }
1500
+
1501
+ // ── Quality command ─────────────────────────────────────────────────────────
1502
+
1503
+ function runQuality(args) {
1504
+ const { opts } = parseCSArgs(args);
1505
+ const days = opts.days ? parseInt(opts.days, 10) : 30;
1506
+ const apply = args.includes('--apply');
1507
+
1508
+ const profile = contentStore.computeQualityProfile({
1509
+ storePath: opts.store || undefined,
1510
+ days,
1511
+ });
1512
+
1513
+ if (opts.json) {
1514
+ console.log(JSON.stringify(profile, null, 2));
1515
+ return;
1516
+ }
1517
+
1518
+ console.log('');
1519
+ console.log('Decision Quality Profile');
1520
+ console.log('========================');
1521
+ console.log(`Period: last ${days} days`);
1522
+ console.log('');
1523
+
1524
+ if (profile.totalDecisions === 0) {
1525
+ console.log('No decisions indexed yet. Run a few sessions, then try again.');
1526
+ console.log('');
1527
+ return;
1528
+ }
1529
+
1530
+ console.log(`Total decisions: ${profile.totalDecisions}`);
1531
+ console.log(`Rich: ${profile.rich} (${profile.richRate}%)`);
1532
+ console.log(`Thin: ${profile.thin}`);
1533
+ console.log('');
1534
+ console.log(` Reasoning: ${profile.reasoning.present}/${profile.totalDecisions} (${profile.reasoning.rate}%)`);
1535
+ console.log(` Alternatives: ${profile.alternatives.present}/${profile.totalDecisions} (${profile.alternatives.rate}%)`);
1536
+ console.log('');
1537
+
1538
+ if (profile.weeklyTrend.length > 1) {
1539
+ console.log('Weekly trend:');
1540
+ for (const { week, richRate, count } of profile.weeklyTrend) {
1541
+ const bar = '\u2588'.repeat(Math.round(richRate / 5));
1542
+ console.log(` ${week} ${bar} ${richRate}% (${count} decisions)`);
1543
+ }
1544
+ console.log('');
1545
+ }
1546
+
1547
+ if (profile.focus.length > 0) {
1548
+ console.log('Elicitation focus:');
1549
+ for (const f of profile.focus) {
1550
+ console.log(` - ${f}`);
1551
+ }
1552
+ console.log('');
1553
+ }
1554
+
1555
+ // Save quality profile to profile.json
1556
+ const existingProfile = readJSONFile(PROFILE_FILE) || {};
1557
+ existingProfile.quality_profile = {
1558
+ computed_at: new Date().toISOString(),
1559
+ days,
1560
+ total_decisions: profile.totalDecisions,
1561
+ rich_rate: profile.richRate,
1562
+ reasoning_rate: profile.reasoning.rate,
1563
+ alternatives_rate: profile.alternatives.rate,
1564
+ focus: profile.focus,
1565
+ };
1566
+ writeJSONFile(PROFILE_FILE, existingProfile);
1567
+ console.log(`Profile saved to ${PROFILE_FILE}`);
1568
+
1569
+ // Generate and optionally apply elicitation focus to personal-state.md
1570
+ if (profile.focus.length > 0 && profile.focus[0] !== 'keep it up — your decision context is strong') {
1571
+ const focusBlock = [
1572
+ '## My Elicitation Focus',
1573
+ '',
1574
+ '<!-- Auto-generated by `wayfind quality`. Updated when you re-run the command. -->',
1575
+ '',
1576
+ 'When making decisions in this repo, the AI should prioritize eliciting:',
1577
+ ...profile.focus.map(f => `- ${f}`),
1578
+ '',
1579
+ ].join('\n');
1580
+
1581
+ if (apply) {
1582
+ // Find personal-state.md in current repo
1583
+ const personalState = path.join(process.cwd(), '.claude', 'personal-state.md');
1584
+ if (fs.existsSync(personalState)) {
1585
+ let content = fs.readFileSync(personalState, 'utf8');
1586
+ // Replace existing section or append
1587
+ if (content.includes('## My Elicitation Focus')) {
1588
+ content = content.replace(
1589
+ /## My Elicitation Focus[\s\S]*?(?=\n## |\n*$)/,
1590
+ focusBlock
1591
+ );
1592
+ } else {
1593
+ content = content.trimEnd() + '\n\n' + focusBlock;
1594
+ }
1595
+ fs.writeFileSync(personalState, content);
1596
+ console.log(`Applied elicitation focus to ${personalState}`);
1597
+ } else {
1598
+ console.log('No personal-state.md found in current repo. Run /init-memory first.');
1599
+ }
1600
+ } else {
1601
+ console.log('');
1602
+ console.log('To apply this focus to your personal-state.md, run:');
1603
+ console.log(' wayfind quality --apply');
1604
+ }
1605
+ }
1606
+
1607
+ console.log('');
1608
+
1609
+ telemetry.capture('quality_profile_viewed', {
1610
+ total_decisions: profile.totalDecisions,
1611
+ rich_rate: profile.richRate,
1612
+ reasoning_rate: profile.reasoning.rate,
1613
+ alternatives_rate: profile.alternatives.rate,
1614
+ }, CLI_USER);
1615
+ }
1616
+
1617
+ // ── Journal command ─────────────────────────────────────────────────────────
1618
+
1619
+ /**
1620
+ * Derive an author slug from a profile name.
1621
+ * Uses the first name, lowercased. e.g. "Greg Leizerowicz" → "greg"
1622
+ */
1623
+ function getAuthorSlug() {
1624
+ const profile = readJSONFile(PROFILE_FILE);
1625
+ if (!profile || !profile.name) return null;
1626
+ return profile.name.split(/\s+/)[0].toLowerCase();
1627
+ }
1628
+
1629
+ function runJournal(args) {
1630
+ const sub = args[0];
1631
+
1632
+ if (sub === 'migrate') {
1633
+ return journalMigrate(args.slice(1));
1634
+ }
1635
+ if (sub === 'sync') {
1636
+ return journalSync(args.slice(1));
1637
+ }
1638
+ if (sub === 'split') {
1639
+ return journalSplitByTeam(args.slice(1));
1640
+ }
1641
+
1642
+ // Default: run legacy journal-summary.sh
1643
+ spawn('bash', [path.join(ROOT, 'journal-summary.sh'), ...args]);
1644
+ }
1645
+
1646
+ /**
1647
+ * Rename YYYY-MM-DD.md → YYYY-MM-DD-{slug}.md in a journal directory.
1648
+ * Adds **Author:** line at the top of each file.
1649
+ * Usage: wayfind journal migrate [--dir <path>] [--author <slug>] [--dry-run]
1650
+ */
1651
+ function journalMigrate(args) {
1652
+ const dryRun = args.includes('--dry-run');
1653
+ const dirIdx = args.indexOf('--dir');
1654
+ const journalDir = dirIdx !== -1 && args[dirIdx + 1]
1655
+ ? path.resolve(args[dirIdx + 1].replace(/^~/, HOME))
1656
+ : contentStore.DEFAULT_JOURNAL_DIR;
1657
+
1658
+ const authorIdx = args.indexOf('--author');
1659
+ const author = authorIdx !== -1 && args[authorIdx + 1]
1660
+ ? args[authorIdx + 1]
1661
+ : getAuthorSlug();
1662
+
1663
+ if (!author) {
1664
+ console.error('Could not determine author. Run "wayfind whoami --setup" or pass --author <slug>.');
1665
+ process.exit(1);
1666
+ }
1667
+
1668
+ if (!fs.existsSync(journalDir)) {
1669
+ console.error(`Journal directory not found: ${journalDir}`);
1670
+ process.exit(1);
1671
+ }
1672
+
1673
+ const plainDateRe = /^(\d{4}-\d{2}-\d{2})\.md$/;
1674
+ const files = fs.readdirSync(journalDir).filter(f => plainDateRe.test(f)).sort();
1675
+
1676
+ if (files.length === 0) {
1677
+ console.log('No plain-date journal files to migrate.');
1678
+ return;
1679
+ }
1680
+
1681
+ console.log(`Migrating ${files.length} journal files → author: ${author}`);
1682
+ if (dryRun) console.log('(dry run — no files will be changed)');
1683
+ console.log('');
1684
+
1685
+ let count = 0;
1686
+ for (const file of files) {
1687
+ const date = file.match(plainDateRe)[1];
1688
+ const newName = `${date}-${author}.md`;
1689
+ const oldPath = path.join(journalDir, file);
1690
+ const newPath = path.join(journalDir, newName);
1691
+
1692
+ if (fs.existsSync(newPath)) {
1693
+ console.log(` SKIP ${file} → ${newName} (target exists)`);
1694
+ continue;
1695
+ }
1696
+
1697
+ // Add **Author:** line at top if not already present
1698
+ let content = fs.readFileSync(oldPath, 'utf8');
1699
+ if (!content.match(/^\*\*Author:\*\*/m)) {
1700
+ content = `**Author:** ${author}\n\n${content}`;
1701
+ }
1702
+
1703
+ if (dryRun) {
1704
+ console.log(` ${file} → ${newName}`);
1705
+ } else {
1706
+ fs.writeFileSync(newPath, content, 'utf8');
1707
+ fs.unlinkSync(oldPath);
1708
+ console.log(` ${file} → ${newName}`);
1709
+ }
1710
+ count++;
1711
+ }
1712
+
1713
+ console.log(`\n${dryRun ? 'Would migrate' : 'Migrated'} ${count} file(s).`);
1714
+ if (!dryRun && count > 0) {
1715
+ console.log('Run "wayfind reindex --journals-only" to update the content store.');
1716
+ }
1717
+ }
1718
+
1719
+ /**
1720
+ * Split existing journal files by team based on repo headers in entries.
1721
+ * Parses ## Org/Repo — headers, resolves repo→team, and creates per-team files.
1722
+ * Old files without team suffix are split; originals renamed to .bak.
1723
+ * Usage: wayfind journal split [--dir <path>] [--dry-run]
1724
+ */
1725
+ function journalSplitByTeam(args) {
1726
+ const dryRun = args.includes('--dry-run');
1727
+ const dirIdx = args.indexOf('--dir');
1728
+ const journalDir = dirIdx !== -1 && args[dirIdx + 1]
1729
+ ? path.resolve(args[dirIdx + 1].replace(/^~/, HOME))
1730
+ : contentStore.DEFAULT_JOURNAL_DIR;
1731
+
1732
+ if (!fs.existsSync(journalDir)) {
1733
+ console.error(`Journal directory not found: ${journalDir}`);
1734
+ process.exit(1);
1735
+ }
1736
+
1737
+ const config = readContextConfig();
1738
+ if (!config.teams) {
1739
+ console.error('No multi-team config found. Run "wayfind context add" first.');
1740
+ process.exit(1);
1741
+ }
1742
+
1743
+ const knownTeamIds = new Set(Object.keys(config.teams));
1744
+ const repoToTeam = buildRepoToTeamResolver();
1745
+
1746
+ // Find files that DON'T already have a team suffix
1747
+ const allFiles = fs.readdirSync(journalDir).filter(f => f.endsWith('.md')).sort();
1748
+ const filesToSplit = allFiles.filter(f => {
1749
+ const base = f.replace(/\.md$/, '');
1750
+ // Already has a team suffix?
1751
+ for (const id of knownTeamIds) {
1752
+ if (base.endsWith(`-${id}`)) return false;
1753
+ }
1754
+ return true;
1755
+ });
1756
+
1757
+ if (filesToSplit.length === 0) {
1758
+ console.log('No journal files need splitting (all already have team suffixes).');
1759
+ return;
1760
+ }
1761
+
1762
+ console.log(`Found ${filesToSplit.length} journal file(s) to split by team.`);
1763
+ if (dryRun) console.log('(dry run — no files will be modified)\n');
1764
+
1765
+ let splitCount = 0;
1766
+
1767
+ for (const file of filesToSplit) {
1768
+ const filePath = path.join(journalDir, file);
1769
+ const content = fs.readFileSync(filePath, 'utf8');
1770
+
1771
+ // Split content into entries by ## headers
1772
+ const lines = content.split('\n');
1773
+ const header = []; // Lines before first ## entry (date header, author line)
1774
+ const entries = []; // { teamId, lines[] }
1775
+ let currentEntry = null;
1776
+
1777
+ for (const line of lines) {
1778
+ const entryMatch = line.match(/^## (.+?) — /);
1779
+ if (entryMatch) {
1780
+ if (currentEntry) entries.push(currentEntry);
1781
+ const repo = entryMatch[1].trim();
1782
+ const teamId = repoToTeam(repo);
1783
+ currentEntry = { teamId, lines: [line] };
1784
+ } else if (currentEntry) {
1785
+ currentEntry.lines.push(line);
1786
+ } else {
1787
+ header.push(line);
1788
+ }
1789
+ }
1790
+ if (currentEntry) entries.push(currentEntry);
1791
+
1792
+ if (entries.length === 0) continue;
1793
+
1794
+ // Group entries by team
1795
+ const teamEntries = {};
1796
+ for (const entry of entries) {
1797
+ const tid = entry.teamId || config.default;
1798
+ if (!teamEntries[tid]) teamEntries[tid] = [];
1799
+ teamEntries[tid].push(entry.lines.join('\n'));
1800
+ }
1801
+
1802
+ // Only one team? Just rename the file with the team suffix
1803
+ const teams = Object.keys(teamEntries);
1804
+ if (teams.length === 1 && teams[0] === config.default) {
1805
+ // All entries belong to default team — add suffix
1806
+ const base = file.replace(/\.md$/, '');
1807
+ const newName = `${base}-${teams[0]}.md`;
1808
+ if (dryRun) {
1809
+ console.log(` ${file} → ${newName} (all entries → ${config.teams[teams[0]]?.name || teams[0]})`);
1810
+ } else {
1811
+ fs.renameSync(filePath, path.join(journalDir, newName));
1812
+ console.log(` ${file} → ${newName}`);
1813
+ }
1814
+ splitCount++;
1815
+ continue;
1816
+ }
1817
+
1818
+ // Multiple teams — write separate files
1819
+ const base = file.replace(/\.md$/, '');
1820
+ const headerText = header.join('\n').trim();
1821
+
1822
+ for (const [teamId, entryTexts] of Object.entries(teamEntries)) {
1823
+ const newName = `${base}-${teamId}.md`;
1824
+ const teamContent = (headerText ? headerText + '\n' : '') + '\n' + entryTexts.join('\n');
1825
+ const teamName = (config.teams[teamId]) ? config.teams[teamId].name : teamId;
1826
+
1827
+ if (dryRun) {
1828
+ console.log(` ${file} → ${newName} (${entryTexts.length} entries → ${teamName})`);
1829
+ } else {
1830
+ fs.writeFileSync(path.join(journalDir, newName), teamContent, 'utf8');
1831
+ console.log(` ${file} → ${newName} (${entryTexts.length} entries → ${teamName})`);
1832
+ }
1833
+ }
1834
+
1835
+ // Rename original to .bak
1836
+ if (!dryRun) {
1837
+ fs.renameSync(filePath, filePath + '.bak');
1838
+ console.log(` ${file} → ${file}.bak (original backed up)`);
1839
+ }
1840
+ splitCount++;
1841
+ }
1842
+
1843
+ console.log(`\n${dryRun ? 'Would split' : 'Split'} ${splitCount} file(s).`);
1844
+ if (!dryRun && splitCount > 0) {
1845
+ console.log('Run "wayfind journal sync" to push split files to team repos.');
1846
+ }
1847
+ }
1848
+
1849
+ /**
1850
+ * Sync local journals to team-context repo(s) journals/ directory.
1851
+ * Routes per-team journal files (YYYY-MM-DD-{teamId}.md or YYYY-MM-DD-{author}-{teamId}.md)
1852
+ * to the correct team-context repo based on the team ID suffix.
1853
+ * Legacy files without a team suffix go to the default team.
1854
+ * Usage: wayfind journal sync [--dir <path>] [--since YYYY-MM-DD]
1855
+ */
1856
+ function journalSync(args) {
1857
+ const dirIdx = args.indexOf('--dir');
1858
+ const journalDir = dirIdx !== -1 && args[dirIdx + 1]
1859
+ ? path.resolve(args[dirIdx + 1].replace(/^~/, HOME))
1860
+ : contentStore.DEFAULT_JOURNAL_DIR;
1861
+
1862
+ const sinceIdx = args.indexOf('--since');
1863
+ const since = sinceIdx !== -1 ? args[sinceIdx + 1] : null;
1864
+
1865
+ const config = readContextConfig();
1866
+ if (!config.teams && !getTeamContextPath()) {
1867
+ // Silent exit when called from session-end hook on machines without team-context
1868
+ return;
1869
+ }
1870
+
1871
+ if (!fs.existsSync(journalDir)) {
1872
+ console.error(`Journal directory not found: ${journalDir}`);
1873
+ process.exit(1);
1874
+ }
1875
+
1876
+ // Auto-migrate any plain-date files (YYYY-MM-DD.md) before syncing
1877
+ const plainDateRe = /^(\d{4}-\d{2}-\d{2})\.md$/;
1878
+ const plainFiles = fs.readdirSync(journalDir).filter(f => plainDateRe.test(f));
1879
+ if (plainFiles.length > 0) {
1880
+ const author = getAuthorSlug();
1881
+ if (author) {
1882
+ let migrated = 0;
1883
+ for (const file of plainFiles) {
1884
+ const date = file.match(plainDateRe)[1];
1885
+ const newName = `${date}-${author}.md`;
1886
+ const oldPath = path.join(journalDir, file);
1887
+ const newPath = path.join(journalDir, newName);
1888
+ if (fs.existsSync(newPath)) continue;
1889
+ let content = fs.readFileSync(oldPath, 'utf8');
1890
+ if (!content.match(/^\*\*Author:\*\*/m)) {
1891
+ content = `**Author:** ${author}\n\n${content}`;
1892
+ }
1893
+ fs.writeFileSync(newPath, content, 'utf8');
1894
+ fs.unlinkSync(oldPath);
1895
+ migrated++;
1896
+ }
1897
+ if (migrated > 0) {
1898
+ console.log(`Auto-migrated ${migrated} journal file(s) → author: ${author}`);
1899
+ }
1900
+ }
1901
+ }
1902
+
1903
+ // Collect all journal files and group by team
1904
+ const allFiles = fs.readdirSync(journalDir).filter(f => f.endsWith('.md')).sort();
1905
+ const knownTeamIds = config.teams ? new Set(Object.keys(config.teams)) : new Set();
1906
+
1907
+ // Categorize files: { teamId → [{ file, srcPath }] }
1908
+ const teamFiles = {};
1909
+ const defaultTeam = config.default || null;
1910
+
1911
+ for (const file of allFiles) {
1912
+ // Extract date for --since filtering
1913
+ const dateMatch = file.match(/^(\d{4}-\d{2}-\d{2})/);
1914
+ if (!dateMatch) continue;
1915
+ if (since && dateMatch[1] < since) continue;
1916
+
1917
+ // Determine team from filename suffix
1918
+ // Pattern: YYYY-MM-DD-{teamId}.md or YYYY-MM-DD-{author}-{teamId}.md
1919
+ let teamId = null;
1920
+ const baseName = file.replace(/\.md$/, '');
1921
+ const parts = baseName.split('-');
1922
+
1923
+ // Check if the last segment (or last N segments joined by -) is a known team ID
1924
+ // Team IDs can contain alphanumeric chars (e.g., "486cbeb4", "personal")
1925
+ if (parts.length > 3) {
1926
+ // Try matching known team IDs from the end of the filename
1927
+ for (const id of knownTeamIds) {
1928
+ if (baseName.endsWith(`-${id}`)) {
1929
+ teamId = id;
1930
+ break;
1931
+ }
1932
+ }
1933
+ }
1934
+
1935
+ // No team suffix → route to default team
1936
+ if (!teamId) teamId = defaultTeam;
1937
+ if (!teamId) continue;
1938
+
1939
+ if (!teamFiles[teamId]) teamFiles[teamId] = [];
1940
+ teamFiles[teamId].push({ file, srcPath: path.join(journalDir, file) });
1941
+ }
1942
+
1943
+ if (Object.keys(teamFiles).length === 0) {
1944
+ console.log('No journal files to sync.');
1945
+ return;
1946
+ }
1947
+
1948
+ // Sync to each team's context repo
1949
+ let totalCopied = 0;
1950
+ let totalSkipped = 0;
1951
+
1952
+ for (const [teamId, files] of Object.entries(teamFiles)) {
1953
+ const teamPath = getTeamContextPath(teamId);
1954
+ if (!teamPath) {
1955
+ console.log(` Skipping ${files.length} file(s) for unknown team: ${teamId}`);
1956
+ continue;
1957
+ }
1958
+
1959
+ const targetDir = path.join(teamPath, 'journals');
1960
+ fs.mkdirSync(targetDir, { recursive: true });
1961
+
1962
+ let copied = 0;
1963
+ let skipped = 0;
1964
+
1965
+ for (const { file, srcPath } of files) {
1966
+ // Strip team suffix from destination filename (team repo doesn't need it)
1967
+ const dstName = file.replace(new RegExp(`-${teamId}\\.md$`), '.md');
1968
+ // But keep author slug if present: YYYY-MM-DD-{author}-{teamId}.md → YYYY-MM-DD-{author}.md
1969
+ const dst = path.join(targetDir, dstName);
1970
+
1971
+ // Skip if target is identical
1972
+ if (fs.existsSync(dst)) {
1973
+ const srcContent = fs.readFileSync(srcPath, 'utf8');
1974
+ const dstContent = fs.readFileSync(dst, 'utf8');
1975
+ if (srcContent === dstContent) {
1976
+ skipped++;
1977
+ continue;
1978
+ }
1979
+ }
1980
+
1981
+ fs.copyFileSync(srcPath, dst);
1982
+ copied++;
1983
+ }
1984
+
1985
+ const teamName = (config.teams && config.teams[teamId]) ? config.teams[teamId].name : teamId;
1986
+ console.log(`Synced to ${targetDir} (${teamName})`);
1987
+ console.log(` ${copied} file(s) copied, ${skipped} unchanged`);
1988
+
1989
+ totalCopied += copied;
1990
+ totalSkipped += skipped;
1991
+
1992
+ if (copied > 0) {
1993
+ commitAndPushTeamJournals(teamPath, copied);
1994
+ } else {
1995
+ // Still stamp version even when no new journals (keeps last_active fresh)
1996
+ stampMemberVersion(teamPath);
1997
+ }
1998
+ }
1999
+
2000
+ telemetry.capture('journal_sync', { file_count: totalCopied }, CLI_USER);
2001
+ }
2002
+
2003
+ /**
2004
+ * Commit and push journal changes in a team-context repo.
2005
+ */
2006
+ function commitAndPushTeamJournals(teamContextPath, copied) {
2007
+ const author = getAuthorSlug() || 'unknown';
2008
+
2009
+ // Stamp current version into member profile
2010
+ stampMemberVersion(teamContextPath);
2011
+
2012
+ try {
2013
+ const gitAdd = spawnSync('git', ['add', 'journals/', 'members/'], { cwd: teamContextPath, stdio: 'pipe' });
2014
+ if (gitAdd.status !== 0) {
2015
+ console.error(`git add failed: ${(gitAdd.stderr || '').toString().trim()}`);
2016
+ return;
2017
+ }
2018
+
2019
+ // Check if there's anything to commit
2020
+ const diffIndex = spawnSync('git', ['diff', '--cached', '--quiet'], { cwd: teamContextPath, stdio: 'pipe' });
2021
+ if (diffIndex.status === 0) {
2022
+ console.log(' Nothing new to commit.');
2023
+ return;
2024
+ }
2025
+
2026
+ const msg = `Sync ${author} journals (${copied} file${copied > 1 ? 's' : ''})`;
2027
+ const gitCommit = spawnSync('git', ['commit', '-m', msg], { cwd: teamContextPath, stdio: 'pipe' });
2028
+ if (gitCommit.status !== 0) {
2029
+ console.error(`git commit failed: ${(gitCommit.stderr || '').toString().trim()}`);
2030
+ return;
2031
+ }
2032
+ console.log(` Committed: ${msg}`);
2033
+
2034
+ const gitPush = spawnSync('git', ['push'], { cwd: teamContextPath, stdio: 'pipe', timeout: 30000 });
2035
+ if (gitPush.status !== 0) {
2036
+ const stderr = (gitPush.stderr || '').toString().trim();
2037
+ if (stderr.includes('fetch first') || stderr.includes('non-fast-forward')) {
2038
+ console.log(' Remote has new changes — rebasing...');
2039
+ const gitPull = spawnSync('git', ['pull', '--rebase'], { cwd: teamContextPath, stdio: 'pipe', timeout: 30000 });
2040
+ if (gitPull.status !== 0) {
2041
+ console.error(` git pull --rebase failed: ${(gitPull.stderr || '').toString().trim()}`);
2042
+ return;
2043
+ }
2044
+ const retry = spawnSync('git', ['push'], { cwd: teamContextPath, stdio: 'pipe', timeout: 30000 });
2045
+ if (retry.status !== 0) {
2046
+ console.error(` git push retry failed: ${(retry.stderr || '').toString().trim()}`);
2047
+ return;
2048
+ }
2049
+ } else {
2050
+ console.error(` git push failed: ${stderr}`);
2051
+ return;
2052
+ }
2053
+ }
2054
+ console.log(' Pushed to remote.');
2055
+ } catch (err) {
2056
+ console.error(` Git sync failed: ${err.message}`);
2057
+ }
2058
+ }
2059
+
2060
+ // ── Status command ──────────────────────────────────────────────────────────
2061
+
2062
+ function runStatus(args) {
2063
+ const doWrite = args.includes('--write');
2064
+ const doJson = args.includes('--json');
2065
+ const quiet = args.includes('--quiet');
2066
+
2067
+ // Configurable scan roots via env or default
2068
+ const envRoots = process.env.AI_MEMORY_SCAN_ROOTS;
2069
+ const roots = envRoots
2070
+ ? envRoots.split(':').filter(Boolean)
2071
+ : rebuildStatus.DEFAULT_ROOTS;
2072
+
2073
+ const stateFiles = rebuildStatus.scanStateFiles(roots);
2074
+
2075
+ if (stateFiles.length === 0 && !quiet) {
2076
+ console.log('No state files found.');
2077
+ console.log(`Scanned: ${roots.join(', ')}`);
2078
+ return;
2079
+ }
2080
+
2081
+ const entries = [];
2082
+ for (const { stateFile } of stateFiles) {
2083
+ const parsed = rebuildStatus.parseStateFile(stateFile);
2084
+ if (parsed) entries.push(parsed);
2085
+ }
2086
+
2087
+ if (doJson) {
2088
+ console.log(JSON.stringify(entries, null, 2));
2089
+ return;
2090
+ }
2091
+
2092
+ const table = rebuildStatus.buildStatusTable(entries);
2093
+
2094
+ if (doWrite) {
2095
+ const globalPath = process.env.TEAM_CONTEXT_GLOBAL_STATE || rebuildStatus.DEFAULT_GLOBAL_STATE;
2096
+ try {
2097
+ const result = rebuildStatus.updateGlobalState(globalPath, table);
2098
+ if (!quiet) {
2099
+ console.log(`Active Projects rebuilt in ${result.path} (${entries.length} repos)`);
2100
+ }
2101
+ } catch (err) {
2102
+ if (!quiet) {
2103
+ console.error(`Error: ${err.message}`);
2104
+ }
2105
+ process.exit(1);
2106
+ }
2107
+ return;
2108
+ }
2109
+
2110
+ // Default: print to stdout
2111
+ if (!quiet) {
2112
+ console.log('');
2113
+ console.log('Cross-project status:');
2114
+ console.log('');
2115
+ console.log(table);
2116
+ console.log('');
2117
+ console.log(`${entries.length} repos scanned from: ${roots.join(', ')}`);
2118
+ console.log('');
2119
+ }
2120
+ }
2121
+
2122
+ // ── Bot command ─────────────────────────────────────────────────────────────
2123
+
2124
+ async function runBot(args) {
2125
+ // --configure: interactive setup
2126
+ if (args.includes('--configure')) {
2127
+ const botConfig = await slackBot.configure();
2128
+ const config = readConnectorsConfig();
2129
+ config.slack_bot = botConfig;
2130
+ writeConnectorsConfig(config);
2131
+ console.log('Slack bot configuration saved to connectors.json.');
2132
+ return;
2133
+ }
2134
+
2135
+ // Default: start the bot
2136
+ const config = readConnectorsConfig();
2137
+ if (!config.slack_bot) {
2138
+ console.error('Slack bot is not configured. Run "wayfind bot --configure" first.');
2139
+ process.exit(1);
2140
+ }
2141
+
2142
+ // Validate tokens are in environment
2143
+ const botTokenEnv = config.slack_bot.bot_token_env || 'SLACK_BOT_TOKEN';
2144
+ const appTokenEnv = config.slack_bot.app_token_env || 'SLACK_APP_TOKEN';
2145
+
2146
+ if (!process.env[botTokenEnv]) {
2147
+ console.error(`Error: ${botTokenEnv} is not set.`);
2148
+ console.error('Run "wayfind bot --configure" to save your tokens.');
2149
+ process.exit(1);
2150
+ }
2151
+ if (!process.env[appTokenEnv]) {
2152
+ console.error(`Error: ${appTokenEnv} is not set.`);
2153
+ console.error('Run "wayfind bot --configure" to save your tokens.');
2154
+ process.exit(1);
2155
+ }
2156
+
2157
+ // Check content store has entries (warn if empty)
2158
+ const index = contentStore.loadIndex(
2159
+ config.slack_bot.store_path || contentStore.DEFAULT_STORE_PATH
2160
+ );
2161
+ if (!index || index.entryCount === 0) {
2162
+ console.log('Warning: Content store is empty. Run "wayfind index-journals" first for best results.');
2163
+ console.log('The bot will still work but may not find relevant results.');
2164
+ console.log('');
2165
+ }
2166
+
2167
+ // Check LLM config
2168
+ const llmConfig = config.slack_bot.llm || {};
2169
+ if (llmConfig.api_key_env && !process.env[llmConfig.api_key_env]) {
2170
+ console.error(`Error: ${llmConfig.api_key_env} is not set.`);
2171
+ console.error('The bot needs an LLM API key for answer synthesis.');
2172
+ console.error('Run "wayfind bot --configure" or set the key in your environment.');
2173
+ process.exit(1);
2174
+ }
2175
+
2176
+ console.log('Starting Wayfind Slack bot...');
2177
+ console.log(`Mode: ${config.slack_bot.mode || 'local'}`);
2178
+ console.log('');
2179
+ await slackBot.start(config.slack_bot);
2180
+ }
2181
+
2182
+ // ── Context command ─────────────────────────────────────────────────────────
2183
+
2184
+ const CONTEXT_CONFIG_FILE = path.join(WAYFIND_DIR, 'context.json');
2185
+
2186
+ function readContextConfig() {
2187
+ const raw = readJSONFile(CONTEXT_CONFIG_FILE) || {};
2188
+ // Auto-migrate legacy single-team format → multi-team registry
2189
+ if (raw.repo_path && !raw.teams) {
2190
+ const team = readJSONFile(TEAM_FILE) || {};
2191
+ const teamId = team.id || team.teamId || 'default';
2192
+ const teamName = team.name || 'default';
2193
+ const migrated = {
2194
+ teams: {
2195
+ [teamId]: { path: raw.repo_path, name: teamName, configured_at: raw.configured_at || new Date().toISOString() },
2196
+ },
2197
+ default: teamId,
2198
+ _migrated_from_repo_path: raw.repo_path,
2199
+ };
2200
+ writeContextConfig(migrated);
2201
+ return migrated;
2202
+ }
2203
+ return raw;
2204
+ }
2205
+
2206
+ function writeContextConfig(config) {
2207
+ ensureWayfindDir();
2208
+ fs.writeFileSync(CONTEXT_CONFIG_FILE, JSON.stringify(config, null, 2) + '\n');
2209
+ }
2210
+
2211
+ /**
2212
+ * Read repo-level team binding from .claude/wayfind.json in cwd.
2213
+ * @returns {string|null} Team ID or null
2214
+ */
2215
+ function readRepoTeamBinding() {
2216
+ const bindingFile = path.join(process.cwd(), '.claude', 'wayfind.json');
2217
+ const binding = readJSONFile(bindingFile);
2218
+ return binding ? binding.team_id : null;
2219
+ }
2220
+
2221
+ /**
2222
+ * Write repo-level team binding to .claude/wayfind.json in cwd.
2223
+ * Also ensures .claude/wayfind.json is gitignored.
2224
+ * @param {string} teamId
2225
+ */
2226
+ function writeRepoTeamBinding(teamId) {
2227
+ const claudeDir = path.join(process.cwd(), '.claude');
2228
+ fs.mkdirSync(claudeDir, { recursive: true });
2229
+ const bindingFile = path.join(claudeDir, 'wayfind.json');
2230
+ const existing = readJSONFile(bindingFile) || {};
2231
+ existing.team_id = teamId;
2232
+ existing.bound_at = new Date().toISOString();
2233
+ fs.writeFileSync(bindingFile, JSON.stringify(existing, null, 2) + '\n');
2234
+
2235
+ // Ensure gitignored
2236
+ const gitignorePath = path.join(process.cwd(), '.gitignore');
2237
+ if (fs.existsSync(gitignorePath)) {
2238
+ const content = fs.readFileSync(gitignorePath, 'utf8');
2239
+ if (!content.includes('.claude/wayfind.json')) {
2240
+ fs.appendFileSync(gitignorePath, '\n.claude/wayfind.json\n');
2241
+ }
2242
+ }
2243
+ }
2244
+
2245
+ /**
2246
+ * Resolve the team-context repo path.
2247
+ * Priority: 1) repo-level .claude/wayfind.json team binding
2248
+ * 2) explicit teamId parameter
2249
+ * 3) default team from context.json
2250
+ * 4) legacy repo_path fallback
2251
+ * @param {string} [teamId] - Explicit team ID override
2252
+ * @returns {string|null}
2253
+ */
2254
+ function getTeamContextPath(teamId) {
2255
+ const config = readContextConfig();
2256
+
2257
+ // Legacy fallback
2258
+ if (!config.teams) {
2259
+ return config.repo_path || null;
2260
+ }
2261
+
2262
+ // Check repo-level team binding if no explicit teamId
2263
+ if (!teamId) {
2264
+ const repoBinding = readRepoTeamBinding();
2265
+ if (repoBinding) teamId = repoBinding;
2266
+ }
2267
+
2268
+ // Fall back to default team
2269
+ if (!teamId) teamId = config.default;
2270
+ if (!teamId) return null;
2271
+
2272
+ const team = config.teams[teamId];
2273
+ return team ? team.path : null;
2274
+ }
2275
+
2276
+ /**
2277
+ * Compare two semver strings. Returns -1, 0, or 1.
2278
+ */
2279
+ function compareSemver(a, b) {
2280
+ const pa = (a || '0.0.0').split('.').map(Number);
2281
+ const pb = (b || '0.0.0').split('.').map(Number);
2282
+ for (let i = 0; i < 3; i++) {
2283
+ if ((pa[i] || 0) < (pb[i] || 0)) return -1;
2284
+ if ((pa[i] || 0) > (pb[i] || 0)) return 1;
2285
+ }
2286
+ return 0;
2287
+ }
2288
+
2289
+ /**
2290
+ * Stamp the current user's version and last_active timestamp into their
2291
+ * member profile in the team-context repo. Called during journal sync.
2292
+ */
2293
+ function stampMemberVersion(teamContextPath) {
2294
+ const profile = readJSONFile(PROFILE_FILE);
2295
+ if (!profile || !profile.name) return;
2296
+
2297
+ const slug = profile.name.toLowerCase().replace(/\s+/g, '-');
2298
+ const memberFile = path.join(teamContextPath, 'members', `${slug}.json`);
2299
+ if (!fs.existsSync(memberFile)) return;
2300
+
2301
+ const member = readJSONFile(memberFile);
2302
+ if (!member) return;
2303
+
2304
+ const version = telemetry.getWayfindVersion();
2305
+ const now = new Date().toISOString();
2306
+
2307
+ // Only write if something changed
2308
+ if (member.wayfind_version === version && member.last_active && member.last_active.slice(0, 10) === now.slice(0, 10)) {
2309
+ return;
2310
+ }
2311
+
2312
+ member.wayfind_version = version;
2313
+ member.last_active = now;
2314
+ fs.writeFileSync(memberFile, JSON.stringify(member, null, 2) + '\n');
2315
+ }
2316
+
2317
+ /**
2318
+ * Read min_version from the team-context repo's wayfind.json.
2319
+ * @param {string} [teamId] - Specific team, or default
2320
+ * @returns {string|null}
2321
+ */
2322
+ function getTeamMinVersion(teamId) {
2323
+ const repoPath = getTeamContextPath(teamId);
2324
+ if (!repoPath) return null;
2325
+ const sharedConfig = readJSONFile(path.join(repoPath, 'wayfind.json'));
2326
+ return sharedConfig ? sharedConfig.min_version || null : null;
2327
+ }
2328
+
2329
+ /**
2330
+ * Check installed version against team min_version.
2331
+ * Returns { ok, installed, required } or null if no min_version set.
2332
+ */
2333
+ function checkMinVersion(teamId) {
2334
+ const minVersion = getTeamMinVersion(teamId);
2335
+ if (!minVersion) return null;
2336
+ const installed = telemetry.getWayfindVersion();
2337
+ return {
2338
+ ok: compareSemver(installed, minVersion) >= 0,
2339
+ installed,
2340
+ required: minVersion,
2341
+ };
2342
+ }
2343
+
2344
+ function syncMemberToRegistry(profile, teamId) {
2345
+ const repoPath = getTeamContextPath();
2346
+ if (!repoPath) {
2347
+ console.log(' (No team-context repo configured — skipping central registry)');
2348
+ return;
2349
+ }
2350
+
2351
+ const slug = profile.name.toLowerCase().replace(/\s+/g, '-');
2352
+ const membersDir = path.join(repoPath, 'members');
2353
+ fs.mkdirSync(membersDir, { recursive: true });
2354
+
2355
+ const memberFile = path.join(membersDir, `${slug}.json`);
2356
+ const memberData = {
2357
+ name: profile.name,
2358
+ personas: profile.personas,
2359
+ joined: profile.created || new Date().toISOString(),
2360
+ teamId,
2361
+ wayfind_version: telemetry.getWayfindVersion(),
2362
+ last_active: new Date().toISOString(),
2363
+ };
2364
+ if (profile.slack_user_id) {
2365
+ memberData.slack_user_id = profile.slack_user_id;
2366
+ }
2367
+ fs.writeFileSync(memberFile, JSON.stringify(memberData, null, 2) + '\n');
2368
+
2369
+ try {
2370
+ const { execSync } = require('child_process');
2371
+ execSync(
2372
+ `git -C "${repoPath}" pull --rebase 2>/dev/null; git -C "${repoPath}" add "members/${slug}.json" && git -C "${repoPath}" commit -m "Add ${profile.name} to team" && git -C "${repoPath}" push`,
2373
+ { stdio: 'pipe' }
2374
+ );
2375
+ console.log(` Registered in team registry: members/${slug}.json`);
2376
+ } catch {
2377
+ console.log(' Could not sync to team registry (git push failed). Your profile is saved locally.');
2378
+ }
2379
+ }
2380
+
2381
+ function syncWebhookToTeamContext(digestConfig) {
2382
+ const webhookUrl =
2383
+ digestConfig && digestConfig.slack && digestConfig.slack.webhook_url;
2384
+ if (!webhookUrl) return;
2385
+
2386
+ const repoPath = getTeamContextPath();
2387
+ if (!repoPath) return;
2388
+
2389
+ const sharedConfigPath = path.join(repoPath, 'wayfind.json');
2390
+ const existing = readJSONFile(sharedConfigPath) || {};
2391
+ if (existing.slack_webhook_url === webhookUrl) return; // already in sync
2392
+
2393
+ existing.slack_webhook_url = webhookUrl;
2394
+ fs.writeFileSync(sharedConfigPath, JSON.stringify(existing, null, 2) + '\n');
2395
+
2396
+ try {
2397
+ const { execSync } = require('child_process');
2398
+ execSync(
2399
+ `git -C "${repoPath}" add wayfind.json && git -C "${repoPath}" commit -m "Update shared Slack webhook for team announcements" && git -C "${repoPath}" push`,
2400
+ { stdio: 'pipe' }
2401
+ );
2402
+ } catch {
2403
+ // Non-fatal — webhook is saved locally in the repo at least
2404
+ }
2405
+ }
2406
+
2407
+ function getTeamWebhookUrl() {
2408
+ // 1. Local connectors config (creator's machine)
2409
+ const config = readConnectorsConfig();
2410
+ const localUrl =
2411
+ config.digest && config.digest.slack && config.digest.slack.webhook_url;
2412
+ if (localUrl) return localUrl;
2413
+
2414
+ // 2. Shared team-context repo config (works for joiners)
2415
+ const repoPath = getTeamContextPath();
2416
+ if (repoPath) {
2417
+ const sharedConfig = readJSONFile(path.join(repoPath, 'wayfind.json'));
2418
+ if (sharedConfig && sharedConfig.slack_webhook_url) {
2419
+ return sharedConfig.slack_webhook_url;
2420
+ }
2421
+ }
2422
+
2423
+ return null;
2424
+ }
2425
+
2426
+ async function announceToSlack(profile, teamId) {
2427
+ try {
2428
+ const webhookUrl = getTeamWebhookUrl();
2429
+ if (!webhookUrl) return;
2430
+
2431
+ const personas = Array.isArray(profile.personas)
2432
+ ? profile.personas.join(', ')
2433
+ : '';
2434
+ await slack.postToWebhook(webhookUrl, {
2435
+ text: `:wave: *${profile.name}* joined the team — personas: ${personas}`,
2436
+ });
2437
+ console.log(' Announced in Slack.');
2438
+ } catch (err) {
2439
+ console.log(` Slack announcement failed: ${err.message}`);
2440
+ }
2441
+ }
2442
+
2443
+ async function runContext(args) {
2444
+ const sub = args[0] || 'show';
2445
+ const subArgs = args.slice(1);
2446
+
2447
+ switch (sub) {
2448
+ case 'init':
2449
+ await contextInit(subArgs);
2450
+ break;
2451
+ case 'sync':
2452
+ contextSync();
2453
+ break;
2454
+ case 'show':
2455
+ contextShow();
2456
+ break;
2457
+ case 'add':
2458
+ contextAdd(subArgs);
2459
+ break;
2460
+ case 'bind':
2461
+ contextBind(subArgs);
2462
+ break;
2463
+ case 'list':
2464
+ contextList();
2465
+ break;
2466
+ case 'default':
2467
+ contextSetDefault(subArgs);
2468
+ break;
2469
+ default:
2470
+ console.error(`Unknown context subcommand: ${sub}`);
2471
+ console.error('Available: init, sync, show, add, bind, list, default');
2472
+ process.exit(1);
2473
+ }
2474
+ }
2475
+
2476
+ async function contextInit(args) {
2477
+ let repoPath = args[0];
2478
+
2479
+ if (!repoPath) {
2480
+ repoPath = await ask('Path to team context repo (e.g. ~/repos/my-org/team-context): ');
2481
+ }
2482
+
2483
+ // Expand ~ to HOME
2484
+ if (repoPath.startsWith('~')) {
2485
+ repoPath = path.join(HOME, repoPath.slice(1));
2486
+ }
2487
+ repoPath = path.resolve(repoPath);
2488
+
2489
+ if (!fs.existsSync(repoPath)) {
2490
+ console.error(`Directory not found: ${repoPath}`);
2491
+ process.exit(1);
2492
+ }
2493
+
2494
+ const contextDir = path.join(repoPath, 'context');
2495
+ if (!fs.existsSync(contextDir)) {
2496
+ console.error(`No context/ directory found in ${repoPath}`);
2497
+ console.error('Create context/ with .md files (e.g. context/product.md) and try again.');
2498
+ process.exit(1);
2499
+ }
2500
+
2501
+ const files = fs.readdirSync(contextDir).filter(f => f.endsWith('.md'));
2502
+ if (files.length === 0) {
2503
+ console.error('No .md files found in context/');
2504
+ process.exit(1);
2505
+ }
2506
+
2507
+ // Register in multi-team context config
2508
+ const config = readContextConfig();
2509
+ const team = readJSONFile(TEAM_FILE) || {};
2510
+ const teamId = team.id || team.teamId || 'default';
2511
+ const teamName = team.name || 'default';
2512
+ if (!config.teams) config.teams = {};
2513
+ config.teams[teamId] = {
2514
+ path: repoPath,
2515
+ name: teamName,
2516
+ configured_at: new Date().toISOString(),
2517
+ };
2518
+ // Set as default if it's the first team
2519
+ if (!config.default || Object.keys(config.teams).length === 1) {
2520
+ config.default = teamId;
2521
+ }
2522
+ writeContextConfig(config);
2523
+
2524
+ // Ensure prompts/ directory exists with README
2525
+ const promptsDir = path.join(repoPath, 'prompts');
2526
+ if (!fs.existsSync(promptsDir)) {
2527
+ fs.mkdirSync(promptsDir, { recursive: true });
2528
+ const readmeSrc = path.join(ROOT, 'templates', 'prompts-readme.md');
2529
+ if (fs.existsSync(readmeSrc)) {
2530
+ fs.copyFileSync(readmeSrc, path.join(promptsDir, 'README.md'));
2531
+ }
2532
+ console.log('Created prompts/ directory with README.');
2533
+ }
2534
+
2535
+ console.log(`Team context repo: ${repoPath}`);
2536
+ console.log(`Found ${files.length} context file(s):`);
2537
+ for (const f of files) {
2538
+ console.log(` ${f}`);
2539
+ }
2540
+ console.log('');
2541
+ console.log('Run "wayfind context sync" in any repo to pull context files.');
2542
+ }
2543
+
2544
+ function contextSync() {
2545
+ const repoPath = getTeamContextPath();
2546
+ if (!repoPath) {
2547
+ console.error('No team context repo configured. Run "wayfind context init <path>" first.');
2548
+ process.exit(1);
2549
+ }
2550
+
2551
+ const sourceDir = path.join(repoPath, 'context');
2552
+ if (!fs.existsSync(sourceDir)) {
2553
+ console.error(`context/ directory not found in ${repoPath}`);
2554
+ process.exit(1);
2555
+ }
2556
+
2557
+ const files = fs.readdirSync(sourceDir).filter(f => f.endsWith('.md'));
2558
+ if (files.length === 0) {
2559
+ console.log('No context files to sync.');
2560
+ return;
2561
+ }
2562
+
2563
+ // Target: .claude/context/ in the current repo
2564
+ const targetDir = path.join(process.cwd(), '.claude', 'context');
2565
+ fs.mkdirSync(targetDir, { recursive: true });
2566
+
2567
+ let synced = 0;
2568
+ for (const file of files) {
2569
+ const src = path.join(sourceDir, file);
2570
+ const dest = path.join(targetDir, file);
2571
+ const srcContent = fs.readFileSync(src, 'utf8');
2572
+
2573
+ // Only write if changed
2574
+ let existing = '';
2575
+ try { existing = fs.readFileSync(dest, 'utf8'); } catch {}
2576
+ if (existing === srcContent) {
2577
+ console.log(` ${file} — up to date`);
2578
+ continue;
2579
+ }
2580
+
2581
+ fs.writeFileSync(dest, srcContent);
2582
+ console.log(` ${file} — synced`);
2583
+ synced++;
2584
+ }
2585
+
2586
+ // Ensure .claude/context/ is gitignored (it's a copy, not source of truth)
2587
+ const gitignorePath = path.join(process.cwd(), '.gitignore');
2588
+ const entry = '.claude/context/';
2589
+ if (fs.existsSync(gitignorePath)) {
2590
+ const content = fs.readFileSync(gitignorePath, 'utf8');
2591
+ if (!content.split('\n').some(line => line.trim() === entry)) {
2592
+ fs.appendFileSync(gitignorePath, `\n# Shared team context (synced by wayfind)\n${entry}\n`);
2593
+ console.log(' Added .claude/context/ to .gitignore');
2594
+ }
2595
+ }
2596
+
2597
+ // Ensure CLAUDE.md references context files
2598
+ const claudeMdPath = path.join(process.cwd(), 'CLAUDE.md');
2599
+ if (fs.existsSync(claudeMdPath)) {
2600
+ const claudeMd = fs.readFileSync(claudeMdPath, 'utf8');
2601
+ if (!claudeMd.includes('.claude/context/')) {
2602
+ const contextBlock = '\n\n## Shared Team Context\n\n' +
2603
+ 'Context files synced from the team context repo (run `wayfind context sync` to update):\n' +
2604
+ files.map(f => `- \`.claude/context/${f}\``).join('\n') + '\n' +
2605
+ '\nThese files are loaded at session start and provide org-wide product, engineering, and strategy context.\n';
2606
+ fs.appendFileSync(claudeMdPath, contextBlock);
2607
+ console.log(' Added context reference to CLAUDE.md');
2608
+ }
2609
+ }
2610
+
2611
+ console.log(`\nSynced ${synced} file(s) to .claude/context/`);
2612
+ }
2613
+
2614
+ function contextShow() {
2615
+ const config = readContextConfig();
2616
+ const repoBinding = readRepoTeamBinding();
2617
+
2618
+ console.log('Team context configuration:');
2619
+ if (config.teams && Object.keys(config.teams).length > 0) {
2620
+ for (const [id, team] of Object.entries(config.teams)) {
2621
+ const isDefault = id === config.default;
2622
+ const isBound = id === repoBinding;
2623
+ const markers = [isDefault ? 'default' : '', isBound ? 'this repo' : ''].filter(Boolean).join(', ');
2624
+ console.log(` ${team.name} (${id})${markers ? ` [${markers}]` : ''}`);
2625
+ console.log(` Path: ${team.path}`);
2626
+ const sourceDir = path.join(team.path, 'context');
2627
+ if (fs.existsSync(sourceDir)) {
2628
+ const files = fs.readdirSync(sourceDir).filter(f => f.endsWith('.md'));
2629
+ console.log(` Context files: ${files.length}`);
2630
+ }
2631
+ }
2632
+ } else {
2633
+ console.log(' Not configured. Run "wayfind context init <path>" to set up.');
2634
+ }
2635
+
2636
+ // Check current repo
2637
+ const localDir = path.join(process.cwd(), '.claude', 'context');
2638
+ if (fs.existsSync(localDir)) {
2639
+ const local = fs.readdirSync(localDir).filter(f => f.endsWith('.md'));
2640
+ console.log(`\nLocal context (.claude/context/):`);
2641
+ for (const f of local) {
2642
+ console.log(` ${f}`);
2643
+ }
2644
+ } else {
2645
+ console.log('\nNo local context in this repo. Run "wayfind context sync" to pull.');
2646
+ }
2647
+ }
2648
+
2649
+ function contextAdd(args) {
2650
+ const teamId = args[0];
2651
+ const repoPath = args[1];
2652
+
2653
+ if (!teamId || !repoPath) {
2654
+ console.error('Usage: wayfind context add <team-id> <path>');
2655
+ console.error('Example: wayfind context add a1b2c3d4 ~/repos/Acme/team-context');
2656
+ process.exit(1);
2657
+ }
2658
+
2659
+ const resolved = path.resolve(repoPath.replace(/^~/, HOME));
2660
+ if (!fs.existsSync(resolved)) {
2661
+ console.error(`Directory not found: ${resolved}`);
2662
+ process.exit(1);
2663
+ }
2664
+
2665
+ const config = readContextConfig();
2666
+ if (!config.teams) config.teams = {};
2667
+
2668
+ // Try to read team name from the repo's wayfind.json
2669
+ const sharedConfig = readJSONFile(path.join(resolved, 'wayfind.json')) || {};
2670
+ const teamName = sharedConfig.team_name || teamId;
2671
+
2672
+ config.teams[teamId] = {
2673
+ path: resolved,
2674
+ name: teamName,
2675
+ configured_at: new Date().toISOString(),
2676
+ };
2677
+ if (!config.default) config.default = teamId;
2678
+ writeContextConfig(config);
2679
+
2680
+ console.log(`Added team "${teamName}" (${teamId})`);
2681
+ console.log(` Path: ${resolved}`);
2682
+ if (Object.keys(config.teams).length === 1) {
2683
+ console.log(' Set as default (only team).');
2684
+ }
2685
+ }
2686
+
2687
+ function contextBind(args) {
2688
+ const teamId = args[0];
2689
+ const config = readContextConfig();
2690
+
2691
+ if (!teamId) {
2692
+ // Show current binding
2693
+ const binding = readRepoTeamBinding();
2694
+ if (binding && config.teams && config.teams[binding]) {
2695
+ console.log(`This repo is bound to: ${config.teams[binding].name} (${binding})`);
2696
+ } else if (binding) {
2697
+ console.log(`This repo is bound to team ID: ${binding} (not found in registry)`);
2698
+ } else {
2699
+ console.log('This repo has no team binding. Using default team.');
2700
+ console.log('Usage: wayfind context bind <team-id>');
2701
+ }
2702
+ return;
2703
+ }
2704
+
2705
+ if (!config.teams || !config.teams[teamId]) {
2706
+ console.error(`Team "${teamId}" not found in registry.`);
2707
+ console.error('Available teams:');
2708
+ if (config.teams) {
2709
+ for (const [id, t] of Object.entries(config.teams)) {
2710
+ console.error(` ${t.name} (${id})`);
2711
+ }
2712
+ }
2713
+ process.exit(1);
2714
+ }
2715
+
2716
+ writeRepoTeamBinding(teamId);
2717
+ console.log(`Bound this repo to: ${config.teams[teamId].name} (${teamId})`);
2718
+ console.log('Journals from this repo will sync to that team\'s context repo.');
2719
+ }
2720
+
2721
+ function contextList() {
2722
+ const config = readContextConfig();
2723
+ const repoBinding = readRepoTeamBinding();
2724
+
2725
+ if (!config.teams || Object.keys(config.teams).length === 0) {
2726
+ console.log('No teams configured. Run "wayfind context init <path>" to set up.');
2727
+ return;
2728
+ }
2729
+
2730
+ console.log('Registered teams:\n');
2731
+ for (const [id, team] of Object.entries(config.teams)) {
2732
+ const isDefault = id === config.default;
2733
+ const isBound = id === repoBinding;
2734
+ const markers = [isDefault ? 'default' : '', isBound ? 'this repo' : ''].filter(Boolean).join(', ');
2735
+ console.log(` ${team.name} (${id})${markers ? ` ← ${markers}` : ''}`);
2736
+ console.log(` ${team.path}`);
2737
+ }
2738
+
2739
+ console.log('');
2740
+ console.log('Commands:');
2741
+ console.log(' wayfind context add <team-id> <path> Register a new team');
2742
+ console.log(' wayfind context bind <team-id> Bind this repo to a team');
2743
+ console.log(' wayfind context default <team-id> Change default team');
2744
+ }
2745
+
2746
+ function contextSetDefault(args) {
2747
+ const teamId = args[0];
2748
+ if (!teamId) {
2749
+ console.error('Usage: wayfind context default <team-id>');
2750
+ process.exit(1);
2751
+ }
2752
+
2753
+ const config = readContextConfig();
2754
+ if (!config.teams || !config.teams[teamId]) {
2755
+ console.error(`Team "${teamId}" not found.`);
2756
+ process.exit(1);
2757
+ }
2758
+
2759
+ config.default = teamId;
2760
+ writeContextConfig(config);
2761
+ console.log(`Default team set to: ${config.teams[teamId].name} (${teamId})`);
2762
+ }
2763
+
2764
+ /**
2765
+ * Try to detect a GitHub token for container use.
2766
+ * Checks: GITHUB_TOKEN env var → `gh auth token` for the team-context remote's org.
2767
+ * Returns the token string or null.
2768
+ */
2769
+ function detectGitHubToken() {
2770
+ // 1. Already in environment
2771
+ if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN;
2772
+
2773
+ // 2. Try gh CLI — use the org-specific account if configured
2774
+ try {
2775
+ const teamContextPath = getTeamContextPath();
2776
+ if (teamContextPath) {
2777
+ // Read the remote URL to determine the GitHub org
2778
+ const remoteResult = spawnSync('git', ['remote', 'get-url', 'origin'], {
2779
+ cwd: teamContextPath, stdio: ['ignore', 'pipe', 'pipe'],
2780
+ });
2781
+ const remoteUrl = (remoteResult.stdout || '').toString().trim();
2782
+ const orgMatch = remoteUrl.match(/github\.com[:/]([^/]+)\//);
2783
+ if (orgMatch) {
2784
+ // Check org-accounts.json for the right gh account
2785
+ const orgAccountsFile = path.join(HOME, '.config', 'gh', 'org-accounts.json');
2786
+ let ghUser = null;
2787
+ try {
2788
+ const orgAccounts = JSON.parse(fs.readFileSync(orgAccountsFile, 'utf8'));
2789
+ ghUser = orgAccounts[orgMatch[1]] || null;
2790
+ } catch { /* no org-accounts mapping */ }
2791
+
2792
+ // Get token for the right account
2793
+ const ghArgs = ghUser ? ['auth', 'token', '--user', ghUser] : ['auth', 'token'];
2794
+ const result = spawnSync('gh', ghArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
2795
+ const token = (result.stdout || '').toString().trim();
2796
+ if (token && result.status === 0) return token;
2797
+ }
2798
+ }
2799
+
2800
+ // Fallback: default gh auth token
2801
+ const result = spawnSync('gh', ['auth', 'token'], { stdio: ['ignore', 'pipe', 'pipe'] });
2802
+ const token = (result.stdout || '').toString().trim();
2803
+ if (token && result.status === 0) return token;
2804
+ } catch { /* gh not installed or not authenticated */ }
2805
+
2806
+ return null;
2807
+ }
2808
+
2809
+ // ── Prompts command ─────────────────────────────────────────────────────────
2810
+
2811
+ function runPrompts(args) {
2812
+ // Find prompts directory
2813
+ const teamDir = process.env.TEAM_CONTEXT_TEAM_CONTEXT_DIR || '';
2814
+ let promptsDir = teamDir ? path.join(teamDir, 'prompts') : '';
2815
+
2816
+ // Fallback: check context config for team context repo path
2817
+ if (!promptsDir || !fs.existsSync(promptsDir)) {
2818
+ const configDir = getTeamContextPath() || '';
2819
+ if (configDir) promptsDir = path.join(configDir, 'prompts');
2820
+ }
2821
+
2822
+ // Fallback: check connectors config for team_context_dir
2823
+ if (!promptsDir || !fs.existsSync(promptsDir)) {
2824
+ const config = readConnectorsConfig();
2825
+ const configDir = (config.digest && config.digest.team_context_dir) || '';
2826
+ if (configDir) promptsDir = path.join(configDir, 'prompts');
2827
+ }
2828
+
2829
+ if (!promptsDir || !fs.existsSync(promptsDir)) {
2830
+ console.log('No prompts directory found. Create a prompts/ directory in your team-context repo.');
2831
+ return;
2832
+ }
2833
+
2834
+ const files = fs.readdirSync(promptsDir)
2835
+ .filter(f => f.endsWith('.md') && f.toLowerCase() !== 'readme.md')
2836
+ .sort();
2837
+
2838
+ // Show specific prompt
2839
+ const name = args.filter(a => !a.startsWith('-')).join(' ').trim();
2840
+ if (name) {
2841
+ const match = files.find(f =>
2842
+ f === name ||
2843
+ f === name + '.md' ||
2844
+ f.replace('.md', '') === name
2845
+ );
2846
+ if (!match) {
2847
+ console.log(`Prompt "${name}" not found. Available: ${files.map(f => f.replace('.md', '')).join(', ')}`);
2848
+ return;
2849
+ }
2850
+ const content = fs.readFileSync(path.join(promptsDir, match), 'utf8');
2851
+ telemetry.capture('prompt_viewed', { prompt_name: match.replace('.md', '') }, CLI_USER);
2852
+ console.log(content);
2853
+ return;
2854
+ }
2855
+
2856
+ // List all prompts
2857
+ if (files.length === 0) {
2858
+ console.log('No prompts yet. Add .md files to your team-context/prompts/ directory.');
2859
+ return;
2860
+ }
2861
+
2862
+ telemetry.capture('prompts_listed', { prompt_count: files.length }, CLI_USER);
2863
+ console.log('Available prompts:\n');
2864
+ for (const file of files) {
2865
+ const content = fs.readFileSync(path.join(promptsDir, file), 'utf8');
2866
+ const firstLine = content.split('\n').find(l => l.trim() && !l.startsWith('#')) || '';
2867
+ const label = file.replace('.md', '');
2868
+ console.log(` ${label}`);
2869
+ if (firstLine.trim()) {
2870
+ console.log(` ${firstLine.trim()}`);
2871
+ }
2872
+ }
2873
+ console.log(`\nRun "wayfind prompts <name>" to view a specific prompt.`);
2874
+ }
2875
+
2876
+ // ── Deploy command ──────────────────────────────────────────────────────────
2877
+
2878
+ const DEPLOY_TEMPLATES_DIR = path.join(ROOT, 'templates', 'deploy');
2879
+
2880
+ async function runDeploy(args) {
2881
+ const sub = args[0] || 'init';
2882
+ switch (sub) {
2883
+ case 'init':
2884
+ deployInit();
2885
+ break;
2886
+ case 'status':
2887
+ deployStatus();
2888
+ break;
2889
+ default:
2890
+ console.error(`Unknown deploy subcommand: ${sub}`);
2891
+ console.error('Available: init, status');
2892
+ process.exit(1);
2893
+ }
2894
+ }
2895
+
2896
+ function deployInit() {
2897
+ const deployDir = path.join(process.cwd(), 'deploy');
2898
+
2899
+ if (fs.existsSync(deployDir)) {
2900
+ console.log('deploy/ directory already exists. Checking for missing files...');
2901
+ } else {
2902
+ fs.mkdirSync(deployDir, { recursive: true });
2903
+ console.log('Created deploy/ directory.');
2904
+ }
2905
+
2906
+ // Copy template files
2907
+ const files = ['docker-compose.yml', '.env.example', 'slack-app-manifest.json'];
2908
+ let copied = 0;
2909
+ for (const file of files) {
2910
+ const dest = path.join(deployDir, file);
2911
+ if (fs.existsSync(dest)) {
2912
+ console.log(` ${file} — already exists, skipping`);
2913
+ continue;
2914
+ }
2915
+ const src = path.join(DEPLOY_TEMPLATES_DIR, file);
2916
+ fs.copyFileSync(src, dest);
2917
+ console.log(` ${file} — created`);
2918
+ copied++;
2919
+ }
2920
+
2921
+ // Pre-fill .env.example with values from connectors.json if available
2922
+ if (copied > 0 && fs.existsSync(CONNECTORS_FILE)) {
2923
+ try {
2924
+ const config = JSON.parse(fs.readFileSync(CONNECTORS_FILE, 'utf8'));
2925
+ const envPath = path.join(deployDir, '.env.example');
2926
+ let envContent = fs.readFileSync(envPath, 'utf8');
2927
+
2928
+ // Substitute placeholder values with real env var names from config
2929
+ if (config.slack_bot) {
2930
+ const botEnv = config.slack_bot.bot_token_env || 'SLACK_BOT_TOKEN';
2931
+ const appEnv = config.slack_bot.app_token_env || 'SLACK_APP_TOKEN';
2932
+ if (process.env[botEnv]) {
2933
+ envContent = envContent.replace('SLACK_BOT_TOKEN=xoxb-your-bot-token', `SLACK_BOT_TOKEN=${process.env[botEnv]}`);
2934
+ }
2935
+ if (process.env[appEnv]) {
2936
+ envContent = envContent.replace('SLACK_APP_TOKEN=xapp-your-app-token', `SLACK_APP_TOKEN=${process.env[appEnv]}`);
2937
+ }
2938
+ }
2939
+ if (config.slack_bot && config.slack_bot.llm && config.slack_bot.llm.api_key_env) {
2940
+ const apiEnv = config.slack_bot.llm.api_key_env;
2941
+ if (process.env[apiEnv]) {
2942
+ envContent = envContent.replace('ANTHROPIC_API_KEY=sk-ant-your-key', `ANTHROPIC_API_KEY=${process.env[apiEnv]}`);
2943
+ }
2944
+ }
2945
+
2946
+ // Auto-detect GITHUB_TOKEN from gh CLI (needed for container git pull)
2947
+ const ghToken = detectGitHubToken();
2948
+ if (ghToken) {
2949
+ envContent = envContent.replace('GITHUB_TOKEN=', `GITHUB_TOKEN=${ghToken}`);
2950
+ }
2951
+
2952
+ fs.writeFileSync(envPath, envContent, 'utf8');
2953
+ } catch (e) {
2954
+ // Non-fatal — pre-fill is best-effort
2955
+ }
2956
+ }
2957
+
2958
+ // Ensure deploy/.env is in .gitignore
2959
+ const gitignorePath = path.join(process.cwd(), '.gitignore');
2960
+ const gitignoreEntry = 'deploy/.env';
2961
+ if (fs.existsSync(gitignorePath)) {
2962
+ const content = fs.readFileSync(gitignorePath, 'utf8');
2963
+ if (!content.split('\n').some(line => line.trim() === gitignoreEntry)) {
2964
+ fs.appendFileSync(gitignorePath, `\n${gitignoreEntry}\n`);
2965
+ console.log(' Added deploy/.env to .gitignore');
2966
+ }
2967
+ } else {
2968
+ fs.writeFileSync(gitignorePath, `${gitignoreEntry}\n`);
2969
+ console.log(' Created .gitignore with deploy/.env');
2970
+ }
2971
+
2972
+ // Auto-create .env from .env.example if it doesn't exist
2973
+ const envPath = path.join(deployDir, '.env');
2974
+ const envExamplePath = path.join(deployDir, '.env.example');
2975
+ if (!fs.existsSync(envPath) && fs.existsSync(envExamplePath)) {
2976
+ fs.copyFileSync(envExamplePath, envPath);
2977
+ console.log(' .env — created from .env.example (fill in your tokens)');
2978
+ }
2979
+
2980
+ // Ensure GITHUB_TOKEN is set in .env (needed for container journal sync)
2981
+ if (fs.existsSync(envPath)) {
2982
+ let envContent = fs.readFileSync(envPath, 'utf8');
2983
+ const hasToken = envContent.split('\n').some(l => {
2984
+ const trimmed = l.trim();
2985
+ return trimmed.startsWith('GITHUB_TOKEN=') && trimmed !== 'GITHUB_TOKEN=' && !trimmed.startsWith('#');
2986
+ });
2987
+ if (!hasToken) {
2988
+ const ghToken = detectGitHubToken();
2989
+ if (ghToken) {
2990
+ envContent = envContent.replace(/^GITHUB_TOKEN=.*$/m, `GITHUB_TOKEN=${ghToken}`);
2991
+ // If no GITHUB_TOKEN line exists at all, append it
2992
+ if (!envContent.includes('GITHUB_TOKEN=')) {
2993
+ envContent += `\nGITHUB_TOKEN=${ghToken}\n`;
2994
+ }
2995
+ fs.writeFileSync(envPath, envContent, 'utf8');
2996
+ console.log(' GITHUB_TOKEN — auto-detected from gh CLI');
2997
+ } else {
2998
+ console.log(' GITHUB_TOKEN — not detected. Set it in deploy/.env for journal sync.');
2999
+ }
3000
+ }
3001
+ }
3002
+
3003
+ console.log('');
3004
+ console.log('Next steps:');
3005
+ console.log('');
3006
+ console.log(' 1. Create your Slack app:');
3007
+ console.log(' Go to api.slack.com/apps → Create New App → From a manifest');
3008
+ console.log(' Paste the contents of deploy/slack-app-manifest.json');
3009
+ console.log('');
3010
+ console.log(' 2. Get your tokens:');
3011
+ console.log(' Bot token (xoxb-): OAuth & Permissions → Bot User OAuth Token');
3012
+ console.log(' App token (xapp-): Basic Information → App-Level Tokens → Generate');
3013
+ console.log(' (add the "connections:write" scope when generating)');
3014
+ console.log('');
3015
+ console.log(' 3. Fill in deploy/.env with your tokens');
3016
+ console.log('');
3017
+ console.log(' 4. Start the services:');
3018
+ console.log(' cd deploy && docker compose up -d');
3019
+ console.log('');
3020
+ console.log(' 5. Verify:');
3021
+ console.log(' curl http://localhost:3141/healthz');
3022
+ console.log('');
3023
+
3024
+ telemetry.capture('deploy_init_completed', { has_github_token: !!detectGitHubToken(), has_embeddings: !!(process.env.OPENAI_API_KEY || process.env.AZURE_OPENAI_EMBEDDING_ENDPOINT) }, CLI_USER);
3025
+ }
3026
+
3027
+ function deployStatus() {
3028
+ const deployDir = path.join(process.cwd(), 'deploy');
3029
+ const envPath = path.join(deployDir, '.env');
3030
+
3031
+ if (!fs.existsSync(deployDir)) {
3032
+ console.log('No deploy/ directory found. Run "wayfind deploy init" first.');
3033
+ process.exit(1);
3034
+ }
3035
+
3036
+ console.log('Deploy files:');
3037
+ const files = ['docker-compose.yml', '.env.example', '.env', 'slack-app-manifest.json'];
3038
+ for (const file of files) {
3039
+ const exists = fs.existsSync(path.join(deployDir, file));
3040
+ console.log(` ${file}: ${exists ? 'present' : 'MISSING'}`);
3041
+ }
3042
+
3043
+ if (!fs.existsSync(envPath)) {
3044
+ console.log('');
3045
+ console.log('.env not found. Copy the example and fill in your values:');
3046
+ console.log(' cp deploy/.env.example deploy/.env');
3047
+ return;
3048
+ }
3049
+
3050
+ // Parse .env and show what's configured vs missing
3051
+ console.log('');
3052
+ console.log('Configuration:');
3053
+ const envContent = fs.readFileSync(envPath, 'utf8');
3054
+ const required = ['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN', 'ANTHROPIC_API_KEY', 'GITHUB_TOKEN'];
3055
+ const optional = ['TEAM_CONTEXT_TENANT_ID', 'SLACK_DIGEST_CHANNEL', 'TEAM_CONTEXT_ENCRYPTION_KEY'];
3056
+
3057
+ const envVars = {};
3058
+ for (const line of envContent.split('\n')) {
3059
+ const trimmed = line.trim();
3060
+ if (!trimmed || trimmed.startsWith('#')) continue;
3061
+ const eq = trimmed.indexOf('=');
3062
+ if (eq === -1) continue;
3063
+ const key = trimmed.slice(0, eq).trim();
3064
+ const val = trimmed.slice(eq + 1).trim();
3065
+ envVars[key] = val;
3066
+ }
3067
+
3068
+ console.log(' Required:');
3069
+ for (const key of required) {
3070
+ const val = envVars[key];
3071
+ const isPlaceholder = !val || val.includes('your-');
3072
+ console.log(` ${key}: ${isPlaceholder ? 'NOT SET' : 'configured'}`);
3073
+ }
3074
+ console.log(' Optional:');
3075
+ for (const key of optional) {
3076
+ const val = envVars[key];
3077
+ console.log(` ${key}: ${val ? 'configured' : 'not set'}`);
3078
+ }
3079
+ }
3080
+
3081
+ // ── Health endpoint ──────────────────────────────────────────────────────────
3082
+
3083
+ let healthStatus = { ok: true, mode: null, started: null, services: {} };
3084
+
3085
+ function startHealthServer() {
3086
+ const port = parseInt(process.env.TEAM_CONTEXT_HEALTH_PORT || '3141', 10);
3087
+ const server = http.createServer((req, res) => {
3088
+ if (req.url === '/healthz' && req.method === 'GET') {
3089
+ // Enrich with index freshness
3090
+ const storePath = process.env.TEAM_CONTEXT_STORE_PATH || contentStore.DEFAULT_STORE_PATH;
3091
+ const index = contentStore.loadIndex(storePath);
3092
+ const indexInfo = index ? {
3093
+ entryCount: index.entryCount || 0,
3094
+ lastUpdated: index.lastUpdated ? new Date(index.lastUpdated).toISOString() : null,
3095
+ stale: index.lastUpdated ? (Date.now() - index.lastUpdated > 2 * 60 * 60 * 1000) : true,
3096
+ } : { entryCount: 0, lastUpdated: null, stale: true };
3097
+
3098
+ // Check Slack WebSocket connection if bot is expected to be running
3099
+ const slackStatus = slackBot.getConnectionStatus();
3100
+ const botExpected = healthStatus.services.bot === 'running';
3101
+ const slackHealthy = !botExpected || slackStatus.connected;
3102
+
3103
+ const response = { ...healthStatus, index: indexInfo, slack: slackStatus };
3104
+ const status = (healthStatus.ok && slackHealthy) ? 200 : 503;
3105
+ res.writeHead(status, { 'Content-Type': 'application/json' });
3106
+ res.end(JSON.stringify(response));
3107
+ } else {
3108
+ res.writeHead(404);
3109
+ res.end();
3110
+ }
3111
+ });
3112
+ server.listen(port, () => {
3113
+ console.log(`Health endpoint: http://0.0.0.0:${port}/healthz`);
3114
+ });
3115
+ return server;
3116
+ }
3117
+
3118
+ // ── Start command (Docker entrypoint) ───────────────────────────────────────
3119
+
3120
+ async function runStart() {
3121
+ const mode = process.env.TEAM_CONTEXT_MODE || 'all-in-one';
3122
+ console.log(`Wayfind starting in ${mode} mode`);
3123
+
3124
+ // Validate required env vars before proceeding
3125
+ const missing = [];
3126
+ if (!process.env.SLACK_BOT_TOKEN) missing.push('SLACK_BOT_TOKEN');
3127
+ if (!process.env.SLACK_APP_TOKEN) missing.push('SLACK_APP_TOKEN');
3128
+ if (!process.env.ANTHROPIC_API_KEY) missing.push('ANTHROPIC_API_KEY');
3129
+ if (missing.length > 0) {
3130
+ console.error('');
3131
+ console.error(`Missing required environment variables: ${missing.join(', ')}`);
3132
+ console.error('');
3133
+ console.error('If running via Docker Compose, create deploy/.env from deploy/.env.example:');
3134
+ console.error(' cp deploy/.env.example deploy/.env');
3135
+ console.error(' # Fill in your tokens, then: docker compose up -d');
3136
+ console.error('');
3137
+ process.exit(1);
3138
+ }
3139
+
3140
+ healthStatus.mode = mode;
3141
+ healthStatus.started = new Date().toISOString();
3142
+ startHealthServer();
3143
+
3144
+ switch (mode) {
3145
+ case 'bot':
3146
+ await runStartBot();
3147
+ break;
3148
+
3149
+ case 'worker':
3150
+ await runStartWorker();
3151
+ process.exit(0);
3152
+ break;
3153
+
3154
+ case 'scheduler':
3155
+ runStartScheduler();
3156
+ break;
3157
+
3158
+ case 'all-in-one':
3159
+ await runStartAllInOne();
3160
+ break;
3161
+
3162
+ default:
3163
+ console.error(`Unknown TEAM_CONTEXT_MODE: ${mode}`);
3164
+ console.error('Valid modes: bot, worker, scheduler, all-in-one');
3165
+ process.exit(1);
3166
+ }
3167
+ }
3168
+
3169
+ async function runStartBot() {
3170
+ ensureContainerConfig();
3171
+ const config = buildBotConfigFromEnv();
3172
+ healthStatus.services.bot = 'starting';
3173
+ console.log('Starting Slack bot (Socket Mode)...');
3174
+ await slackBot.start(config);
3175
+ healthStatus.services.bot = 'running';
3176
+ console.log('Slack bot connected.');
3177
+ }
3178
+
3179
+ async function runStartWorker() {
3180
+ const job = process.env.TEAM_CONTEXT_JOB;
3181
+ if (!job) {
3182
+ console.error('TEAM_CONTEXT_JOB is required in worker mode.');
3183
+ console.error('Valid jobs: digest, pull, index-journals, index-conversations, reindex');
3184
+ process.exit(1);
3185
+ }
3186
+
3187
+ // Ensure connectors config exists from env vars (no interactive prompts in container)
3188
+ ensureContainerConfig();
3189
+
3190
+ healthStatus.services.worker = `running:${job}`;
3191
+ console.log(`Running job: ${job}`);
3192
+
3193
+ switch (job) {
3194
+ case 'digest':
3195
+ await runDigest(buildDigestArgsFromEnv());
3196
+ break;
3197
+ case 'pull':
3198
+ await runPull(['--all']);
3199
+ break;
3200
+ case 'index-journals':
3201
+ await runIndexJournals([]);
3202
+ break;
3203
+ case 'index-conversations':
3204
+ await runIndexConversations([]);
3205
+ break;
3206
+ case 'reindex':
3207
+ await runReindex([]);
3208
+ break;
3209
+ default:
3210
+ console.error(`Unknown job: ${job}`);
3211
+ process.exit(1);
3212
+ }
3213
+
3214
+ healthStatus.services.worker = `completed:${job}`;
3215
+ }
3216
+
3217
+ function runStartScheduler() {
3218
+ const nodeSchedule = (() => {
3219
+ // Simple cron-like scheduler using setTimeout
3220
+ // Parses standard 5-field cron expressions
3221
+ return { schedule: scheduleCron };
3222
+ })();
3223
+
3224
+ const digestSchedule = process.env.TEAM_CONTEXT_DIGEST_SCHEDULE || '0 12 * * *';
3225
+ const signalSchedule = process.env.TEAM_CONTEXT_SIGNAL_SCHEDULE || '0 6 * * *';
3226
+
3227
+ console.log(`Digest schedule: ${digestSchedule}`);
3228
+ console.log(`Signal schedule: ${signalSchedule}`);
3229
+
3230
+ healthStatus.services.scheduler = 'running';
3231
+
3232
+ scheduleCron(digestSchedule, async () => {
3233
+ console.log(`[${new Date().toISOString()}] Triggering digest...`);
3234
+ try {
3235
+ await runDigest(buildDigestArgsFromEnv());
3236
+ console.log(`[${new Date().toISOString()}] Digest complete.`);
3237
+ } catch (err) {
3238
+ console.error(`Digest failed: ${err.message}`);
3239
+ }
3240
+ });
3241
+
3242
+ scheduleCron(signalSchedule, async () => {
3243
+ console.log(`[${new Date().toISOString()}] Triggering signal pull...`);
3244
+ try {
3245
+ await runPull(['--all']);
3246
+ console.log(`[${new Date().toISOString()}] Signal pull complete.`);
3247
+ } catch (err) {
3248
+ console.error(`Signal pull failed: ${err.message}`);
3249
+ }
3250
+ });
3251
+
3252
+ // Re-index all sources hourly so the bot sees new entries
3253
+ const reindexSchedule = process.env.TEAM_CONTEXT_REINDEX_SCHEDULE || '0 * * * *';
3254
+ console.log(`Reindex schedule: ${reindexSchedule}`);
3255
+ scheduleCron(reindexSchedule, async () => {
3256
+ await pullTeamContext();
3257
+ console.log(`[${new Date().toISOString()}] Re-indexing journals...`);
3258
+ await indexJournalsIfAvailable();
3259
+ console.log(`[${new Date().toISOString()}] Re-indexing conversations...`);
3260
+ await indexConversationsIfAvailable();
3261
+ console.log(`[${new Date().toISOString()}] Re-indexing signals...`);
3262
+ await indexSignalsIfAvailable();
3263
+ });
3264
+
3265
+ console.log('Scheduler running. Waiting for scheduled events...');
3266
+ }
3267
+
3268
+ /**
3269
+ * Pull latest changes from the team-context repo inside the container.
3270
+ * Uses GITHUB_TOKEN for HTTPS auth if the remote is GitHub.
3271
+ * Skipped silently if TEAM_CONTEXT_TEAM_CONTEXT_DIR is not set or not a git repo.
3272
+ */
3273
+ async function pullTeamContext() {
3274
+ const teamDir = process.env.TEAM_CONTEXT_TEAM_CONTEXT_DIR;
3275
+ if (!teamDir || !fs.existsSync(path.join(teamDir, '.git'))) return;
3276
+
3277
+ const token = process.env.GITHUB_TOKEN;
3278
+ const env = { ...process.env };
3279
+
3280
+ // Build git config entries via environment
3281
+ const gitConfig = [
3282
+ // Mark the mounted directory as safe (owned by host user, not container user)
3283
+ ['safe.directory', teamDir],
3284
+ ];
3285
+
3286
+ // Configure git to use GITHUB_TOKEN for HTTPS pulls
3287
+ if (token) {
3288
+ env.GIT_ASKPASS = 'echo';
3289
+ env.GIT_TERMINAL_PROMPT = '0';
3290
+ gitConfig.push(['credential.helper', '']);
3291
+ gitConfig.push([`url.https://x-access-token:${token}@github.com/.insteadOf`, 'https://github.com/']);
3292
+ }
3293
+
3294
+ env.GIT_CONFIG_COUNT = String(gitConfig.length);
3295
+ for (let i = 0; i < gitConfig.length; i++) {
3296
+ env[`GIT_CONFIG_KEY_${i}`] = gitConfig[i][0];
3297
+ env[`GIT_CONFIG_VALUE_${i}`] = gitConfig[i][1];
3298
+ }
3299
+
3300
+ try {
3301
+ const result = spawnSync('git', ['pull', '--ff-only', '-q'], {
3302
+ cwd: teamDir,
3303
+ env,
3304
+ timeout: 30000,
3305
+ stdio: ['ignore', 'pipe', 'pipe'],
3306
+ });
3307
+ if (result.status === 0) {
3308
+ const output = (result.stdout || '').toString().trim();
3309
+ if (output && output !== 'Already up to date.') {
3310
+ console.log(`[${new Date().toISOString()}] Team context updated: ${output}`);
3311
+ }
3312
+ } else {
3313
+ const stderr = (result.stderr || '').toString().trim();
3314
+ console.error(`[${new Date().toISOString()}] git pull failed: ${stderr}`);
3315
+ }
3316
+ } catch (err) {
3317
+ console.error(`[${new Date().toISOString()}] git pull error: ${err.message}`);
3318
+ }
3319
+ }
3320
+
3321
+ async function indexJournalsIfAvailable() {
3322
+ const journalDir = process.env.TEAM_CONTEXT_JOURNALS_DIR || '/data/journals';
3323
+ if (!fs.existsSync(journalDir)) {
3324
+ console.log(`No journals at ${journalDir} — skipping index.`);
3325
+ return;
3326
+ }
3327
+ const entries = fs.readdirSync(journalDir).filter(f => f.endsWith('.md'));
3328
+ if (entries.length === 0) {
3329
+ console.log('No journal files found — skipping index.');
3330
+ return;
3331
+ }
3332
+ const hasEmbeddingKey = !!process.env.OPENAI_API_KEY;
3333
+ console.log(`Indexing ${entries.length} journal files from ${journalDir}${hasEmbeddingKey ? ' (with embeddings)' : ''}...`);
3334
+ try {
3335
+ const stats = await contentStore.indexJournals({
3336
+ journalDir,
3337
+ embeddings: hasEmbeddingKey,
3338
+ });
3339
+ console.log(`Indexed ${stats.entryCount} entries (${stats.newEntries} new, ${stats.updatedEntries} updated).`);
3340
+ } catch (err) {
3341
+ console.error(`Journal indexing failed: ${err.message}`);
3342
+ }
3343
+ }
3344
+
3345
+ async function indexConversationsIfAvailable() {
3346
+ const projectsDir = process.env.TEAM_CONTEXT_CONVERSATIONS_DIR || contentStore.DEFAULT_PROJECTS_DIR;
3347
+ if (!projectsDir || !fs.existsSync(projectsDir)) {
3348
+ console.log(`No conversations at ${projectsDir} — skipping.`);
3349
+ return;
3350
+ }
3351
+ const hasLlmKey = !!process.env.ANTHROPIC_API_KEY;
3352
+ if (!hasLlmKey) {
3353
+ console.log('No ANTHROPIC_API_KEY — skipping conversation extraction.');
3354
+ return;
3355
+ }
3356
+ try {
3357
+ const stats = await contentStore.indexConversations({
3358
+ projectsDir,
3359
+ embeddings: !!(process.env.OPENAI_API_KEY || process.env.AZURE_OPENAI_EMBEDDING_ENDPOINT),
3360
+ });
3361
+ console.log(`Conversations: ${stats.transcriptsProcessed} processed, ${stats.decisionsExtracted} decisions extracted (${stats.skipped} skipped).`);
3362
+ } catch (err) {
3363
+ console.error(`Conversation indexing failed: ${err.message}`);
3364
+ }
3365
+ }
3366
+
3367
+ async function indexSignalsIfAvailable() {
3368
+ const signalsDir = process.env.TEAM_CONTEXT_SIGNALS_DIR || contentStore.DEFAULT_SIGNALS_DIR;
3369
+ if (!signalsDir || !fs.existsSync(signalsDir)) {
3370
+ console.log(`No signals at ${signalsDir} — skipping index.`);
3371
+ return;
3372
+ }
3373
+ const hasEmbeddingKey = !!(process.env.OPENAI_API_KEY || process.env.AZURE_OPENAI_EMBEDDING_ENDPOINT);
3374
+ console.log(`Indexing signals from ${signalsDir}${hasEmbeddingKey ? ' (with embeddings)' : ''}...`);
3375
+ try {
3376
+ const stats = await contentStore.indexSignals({
3377
+ signalsDir,
3378
+ embeddings: hasEmbeddingKey,
3379
+ });
3380
+ console.log(`Signals: ${stats.fileCount} files (${stats.newEntries} new, ${stats.updatedEntries} updated, ${stats.skippedEntries} skipped).`);
3381
+ } catch (err) {
3382
+ console.error(`Signal indexing failed: ${err.message}`);
3383
+ }
3384
+ }
3385
+
3386
+ async function runStartAllInOne() {
3387
+ // Start bot in background, run scheduler in foreground
3388
+ console.log('All-in-one mode: starting bot + scheduler');
3389
+
3390
+ // Pull latest journals from team-context repo before indexing
3391
+ await pullTeamContext();
3392
+
3393
+ // Pull signals at startup so the bot has signal data immediately
3394
+ try {
3395
+ const config = readConnectorsConfig();
3396
+ const channels = Object.keys(config).filter((k) => connectors.get(k));
3397
+ if (channels.length > 0) {
3398
+ console.log('Pulling signals at startup...');
3399
+ await runPull(['--all']);
3400
+ }
3401
+ } catch (err) {
3402
+ console.error(`Startup signal pull failed: ${err.message}`);
3403
+ }
3404
+
3405
+ // Index journals, conversations, and signals before starting bot so it has content to search
3406
+ await indexJournalsIfAvailable();
3407
+ await indexConversationsIfAvailable();
3408
+ await indexSignalsIfAvailable();
3409
+
3410
+ // Start bot
3411
+ try {
3412
+ await runStartBot();
3413
+ } catch (err) {
3414
+ console.error(`Bot failed to start: ${err.message}`);
3415
+ console.log('Continuing with scheduler only...');
3416
+ healthStatus.services.bot = `error:${err.message}`;
3417
+ }
3418
+
3419
+ // Start scheduler
3420
+ runStartScheduler();
3421
+ }
3422
+
3423
+ function ensureContainerConfig() {
3424
+ // In container mode, build connectors.json from environment variables
3425
+ // so existing commands work without interactive setup
3426
+ const config = readConnectorsConfig();
3427
+ let changed = false;
3428
+
3429
+ // Digest config
3430
+ if (!config.digest && process.env.ANTHROPIC_API_KEY) {
3431
+ config.digest = {
3432
+ llm: {
3433
+ provider: 'anthropic',
3434
+ model: process.env.TEAM_CONTEXT_LLM_MODEL || 'claude-sonnet-4-5-20250929',
3435
+ api_key_env: 'ANTHROPIC_API_KEY',
3436
+ },
3437
+ lookback_days: 7,
3438
+ store_path: process.env.TEAM_CONTEXT_STORE_PATH || contentStore.DEFAULT_STORE_PATH,
3439
+ journal_dir: process.env.TEAM_CONTEXT_JOURNALS_DIR || '/data/journals',
3440
+ signals_dir: process.env.TEAM_CONTEXT_SIGNALS_DIR || contentStore.DEFAULT_SIGNALS_DIR,
3441
+ team_context_dir: process.env.TEAM_CONTEXT_TEAM_CONTEXT_DIR || '',
3442
+ slack: {
3443
+ webhook_url: process.env.TEAM_CONTEXT_SLACK_WEBHOOK || '',
3444
+ default_personas: ['unified'],
3445
+ },
3446
+ };
3447
+ changed = true;
3448
+ }
3449
+
3450
+ // Slack bot config
3451
+ if (!config.slack_bot && process.env.SLACK_BOT_TOKEN) {
3452
+ config.slack_bot = {
3453
+ bot_token_env: 'SLACK_BOT_TOKEN',
3454
+ app_token_env: 'SLACK_APP_TOKEN',
3455
+ mode: process.env.TEAM_CONTEXT_BOT_MODE || 'local',
3456
+ store_path: process.env.TEAM_CONTEXT_STORE_PATH || contentStore.DEFAULT_STORE_PATH,
3457
+ journal_dir: process.env.TEAM_CONTEXT_JOURNALS_DIR || '/data/journals',
3458
+ llm: {
3459
+ provider: 'anthropic',
3460
+ model: process.env.TEAM_CONTEXT_LLM_MODEL || 'claude-sonnet-4-5-20250929',
3461
+ api_key_env: 'ANTHROPIC_API_KEY',
3462
+ },
3463
+ };
3464
+ changed = true;
3465
+ }
3466
+
3467
+ // GitHub connector
3468
+ if (!config.github && process.env.GITHUB_TOKEN) {
3469
+ const repos = process.env.TEAM_CONTEXT_GITHUB_REPOS;
3470
+ if (repos) {
3471
+ config.github = {
3472
+ transport: 'https',
3473
+ token: process.env.GITHUB_TOKEN,
3474
+ token_env: 'GITHUB_TOKEN',
3475
+ repos: repos.split(',').map((r) => r.trim()),
3476
+ last_pull: null,
3477
+ };
3478
+ changed = true;
3479
+ }
3480
+ }
3481
+
3482
+ // Intercom connector
3483
+ if (!config.intercom && process.env.INTERCOM_TOKEN) {
3484
+ const tagFilter = process.env.TEAM_CONTEXT_INTERCOM_TAGS;
3485
+ config.intercom = {
3486
+ transport: 'https',
3487
+ token_env: 'INTERCOM_TOKEN',
3488
+ token: process.env.INTERCOM_TOKEN,
3489
+ tag_filter: tagFilter ? tagFilter.split(',').map((t) => t.trim()) : null,
3490
+ last_pull: null,
3491
+ };
3492
+ changed = true;
3493
+ }
3494
+
3495
+ // Notion connector
3496
+ if (!config.notion && process.env.NOTION_TOKEN) {
3497
+ const databases = process.env.TEAM_CONTEXT_NOTION_DATABASES;
3498
+ config.notion = {
3499
+ transport: 'https',
3500
+ token: process.env.NOTION_TOKEN,
3501
+ token_env: 'NOTION_TOKEN',
3502
+ databases: databases ? databases.split(',').map((d) => d.trim()) : null,
3503
+ last_pull: null,
3504
+ };
3505
+ changed = true;
3506
+ }
3507
+
3508
+ if (changed) {
3509
+ ensureWayfindDir();
3510
+ writeConnectorsConfig(config);
3511
+ console.log('Container config: connectors.json built from environment variables.');
3512
+ }
3513
+ }
3514
+
3515
+ function buildBotConfigFromEnv() {
3516
+ return {
3517
+ bot_token_env: 'SLACK_BOT_TOKEN',
3518
+ app_token_env: 'SLACK_APP_TOKEN',
3519
+ mode: process.env.TEAM_CONTEXT_BOT_MODE || 'local',
3520
+ store_path: process.env.TEAM_CONTEXT_STORE_PATH || contentStore.DEFAULT_STORE_PATH,
3521
+ journal_dir: process.env.TEAM_CONTEXT_JOURNALS_DIR || '/data/journals',
3522
+ llm: {
3523
+ provider: 'anthropic',
3524
+ model: process.env.TEAM_CONTEXT_LLM_MODEL || 'claude-sonnet-4-5-20250929',
3525
+ api_key_env: 'ANTHROPIC_API_KEY',
3526
+ },
3527
+ };
3528
+ }
3529
+
3530
+ function buildDigestArgsFromEnv() {
3531
+ const args = [];
3532
+ if (process.env.SLACK_DIGEST_CHANNEL) {
3533
+ args.push('--deliver');
3534
+ }
3535
+ return args;
3536
+ }
3537
+
3538
+ // ── Cron parser (minimal, no external deps) ─────────────────────────────────
3539
+
3540
+ function scheduleCron(expression, callback) {
3541
+ const fields = expression.trim().split(/\s+/);
3542
+ if (fields.length !== 5) {
3543
+ console.error(`Invalid cron expression: ${expression}`);
3544
+ return;
3545
+ }
3546
+
3547
+ function matches(field, value) {
3548
+ if (field === '*') return true;
3549
+ // Handle lists: 1,3,5
3550
+ const parts = field.split(',');
3551
+ for (const part of parts) {
3552
+ // Handle ranges: 1-5
3553
+ if (part.includes('-')) {
3554
+ const [lo, hi] = part.split('-').map(Number);
3555
+ if (value >= lo && value <= hi) return true;
3556
+ }
3557
+ // Handle step: */5
3558
+ else if (part.includes('/')) {
3559
+ const [, step] = part.split('/');
3560
+ if (value % parseInt(step, 10) === 0) return true;
3561
+ }
3562
+ // Exact match
3563
+ else if (parseInt(part, 10) === value) return true;
3564
+ }
3565
+ return false;
3566
+ }
3567
+
3568
+ function check() {
3569
+ const now = new Date();
3570
+ const [minute, hour, dayOfMonth, month, dayOfWeek] = fields;
3571
+ if (
3572
+ matches(minute, now.getMinutes()) &&
3573
+ matches(hour, now.getHours()) &&
3574
+ matches(dayOfMonth, now.getDate()) &&
3575
+ matches(month, now.getMonth() + 1) &&
3576
+ matches(dayOfWeek, now.getDay())
3577
+ ) {
3578
+ callback();
3579
+ }
3580
+ }
3581
+
3582
+ // Check every 60 seconds, aligned to the start of each minute
3583
+ const msUntilNextMinute = (60 - new Date().getSeconds()) * 1000;
3584
+ setTimeout(() => {
3585
+ check();
3586
+ setInterval(check, 60 * 1000);
3587
+ }, msUntilNextMinute);
3588
+ }
3589
+
3590
+ // ── Members command ─────────────────────────────────────────────────────────
3591
+
3592
+ function runMembers(args) {
3593
+ const config = readContextConfig();
3594
+ const currentVersion = telemetry.getWayfindVersion();
3595
+ const doJson = args.includes('--json');
3596
+ const doSetMinVersion = args.includes('--set-min-version');
3597
+
3598
+ // wayfind members --set-min-version 1.8.28
3599
+ if (doSetMinVersion) {
3600
+ const vIdx = args.indexOf('--set-min-version');
3601
+ const version = args[vIdx + 1];
3602
+ if (!version || !/^\d+\.\d+\.\d+$/.test(version)) {
3603
+ console.error('Usage: wayfind members --set-min-version <X.Y.Z>');
3604
+ process.exit(1);
3605
+ }
3606
+ const repoPath = getTeamContextPath();
3607
+ if (!repoPath) {
3608
+ console.error('No team-context repo configured.');
3609
+ process.exit(1);
3610
+ }
3611
+ const sharedConfigPath = path.join(repoPath, 'wayfind.json');
3612
+ const existing = readJSONFile(sharedConfigPath) || {};
3613
+ existing.min_version = version;
3614
+ fs.writeFileSync(sharedConfigPath, JSON.stringify(existing, null, 2) + '\n');
3615
+
3616
+ try {
3617
+ spawnSync('git', ['add', 'wayfind.json'], { cwd: repoPath, stdio: 'pipe' });
3618
+ spawnSync('git', ['commit', '-m', `Set minimum Wayfind version to v${version}`], { cwd: repoPath, stdio: 'pipe' });
3619
+ spawnSync('git', ['push'], { cwd: repoPath, stdio: 'pipe' });
3620
+ } catch { /* non-fatal */ }
3621
+
3622
+ console.log(`Minimum version set to v${version}`);
3623
+ telemetry.capture('min_version_set', { min_version: version }, CLI_USER);
3624
+ return;
3625
+ }
3626
+
3627
+ // Gather members from all teams (or default team)
3628
+ const allMembers = [];
3629
+ const teamIds = config.teams ? Object.keys(config.teams) : [];
3630
+
3631
+ if (teamIds.length === 0) {
3632
+ const repoPath = getTeamContextPath();
3633
+ if (repoPath) teamIds.push('_default');
3634
+ }
3635
+
3636
+ for (const teamId of teamIds) {
3637
+ const repoPath = teamId === '_default' ? getTeamContextPath() : getTeamContextPath(teamId);
3638
+ if (!repoPath) continue;
3639
+
3640
+ const membersDir = path.join(repoPath, 'members');
3641
+ if (!fs.existsSync(membersDir)) continue;
3642
+
3643
+ const minVersion = getTeamMinVersion(teamId === '_default' ? undefined : teamId);
3644
+ const teamName = config.teams && config.teams[teamId] ? config.teams[teamId].name : 'default';
3645
+
3646
+ for (const file of fs.readdirSync(membersDir).filter(f => f.endsWith('.json'))) {
3647
+ const member = readJSONFile(path.join(membersDir, file));
3648
+ if (!member) continue;
3649
+
3650
+ const version = member.wayfind_version || 'unknown';
3651
+ const outdated = minVersion && version !== 'unknown' ? compareSemver(version, minVersion) < 0 : false;
3652
+
3653
+ allMembers.push({
3654
+ name: member.name || file.replace('.json', ''),
3655
+ version,
3656
+ last_active: member.last_active || null,
3657
+ personas: member.personas || [],
3658
+ team: teamName,
3659
+ teamId: teamId === '_default' ? null : teamId,
3660
+ outdated,
3661
+ min_version: minVersion,
3662
+ });
3663
+ }
3664
+ }
3665
+
3666
+ if (allMembers.length === 0) {
3667
+ console.log('No team members found.');
3668
+ console.log('Run "wayfind whoami --setup" to create your profile.');
3669
+ return;
3670
+ }
3671
+
3672
+ if (doJson) {
3673
+ console.log(JSON.stringify(allMembers, null, 2));
3674
+ return;
3675
+ }
3676
+
3677
+ // Table output
3678
+ const minVersion = allMembers[0] && allMembers[0].min_version;
3679
+ if (minVersion) {
3680
+ console.log(`Minimum version: v${minVersion} | Your version: v${currentVersion}`);
3681
+ console.log('');
3682
+ }
3683
+
3684
+ console.log(
3685
+ ' ' +
3686
+ 'Name'.padEnd(22) +
3687
+ 'Version'.padEnd(14) +
3688
+ 'Last Active'.padEnd(14) +
3689
+ 'Personas'
3690
+ );
3691
+ console.log(' ' + '─'.repeat(62));
3692
+
3693
+ for (const m of allMembers) {
3694
+ const versionStr = m.version === 'unknown' ? '?' : `v${m.version}`;
3695
+ const flag = m.outdated ? ' !' : ' ';
3696
+ const lastActive = m.last_active ? m.last_active.slice(0, 10) : '—';
3697
+ const personas = m.personas.join(', ');
3698
+ console.log(
3699
+ flag +
3700
+ m.name.padEnd(22) +
3701
+ versionStr.padEnd(14) +
3702
+ lastActive.padEnd(14) +
3703
+ personas
3704
+ );
3705
+ }
3706
+
3707
+ const outdatedCount = allMembers.filter(m => m.outdated).length;
3708
+ if (outdatedCount > 0) {
3709
+ console.log('');
3710
+ console.log(` ! = below minimum version (v${minVersion})`);
3711
+ console.log(` ${outdatedCount} member(s) need to run: npm update -g wayfind`);
3712
+ }
3713
+ }
3714
+
3715
+ /**
3716
+ * Check min_version at session start. Called from the session-start hook
3717
+ * via `wayfind check-version`. Prints a warning and fires telemetry
3718
+ * if the installed version is below the team minimum.
3719
+ */
3720
+ function runCheckVersion() {
3721
+ // Stamp member profile on every session start so version/last_active stay current
3722
+ const config = readContextConfig();
3723
+ const teamIds = config.teams ? Object.keys(config.teams) : [];
3724
+ for (const teamId of teamIds) {
3725
+ const repoPath = getTeamContextPath(teamId);
3726
+ if (repoPath) stampMemberVersion(repoPath);
3727
+ }
3728
+ if (teamIds.length === 0) {
3729
+ const repoPath = getTeamContextPath();
3730
+ if (repoPath) stampMemberVersion(repoPath);
3731
+ }
3732
+
3733
+ const result = checkMinVersion();
3734
+ if (!result) return; // no min_version configured
3735
+ if (result.ok) return; // version is fine
3736
+
3737
+ console.error(`\x1b[33m⚠ Wayfind v${result.installed} is below team minimum v${result.required}\x1b[0m`);
3738
+ console.error(' Run: npm update -g wayfind');
3739
+
3740
+ telemetry.capture('version_outdated', {
3741
+ installed_version: result.installed,
3742
+ required_version: result.required,
3743
+ }, CLI_USER);
3744
+ }
3745
+
3746
+ // ── Command registry ────────────────────────────────────────────────────────
3747
+
3748
+ const COMMANDS = {
3749
+ start: {
3750
+ desc: 'Start Wayfind in container mode (reads TEAM_CONTEXT_MODE env var)',
3751
+ run: () => runStart(),
3752
+ },
3753
+ init: {
3754
+ desc: 'Install Wayfind for your AI tool (Claude Code, Cursor, or generic)',
3755
+ run: (args) => {
3756
+ const hasToolFlag = args.some((a) => a === '--tool' || a.startsWith('--tool='));
3757
+ const toolArgs = hasToolFlag ? args : ['--tool', 'claude-code', ...args];
3758
+ spawn('bash', [path.join(ROOT, 'setup.sh'), ...toolArgs]);
3759
+ },
3760
+ },
3761
+ 'init-cursor': {
3762
+ desc: 'Install Wayfind for Cursor',
3763
+ run: (args) => {
3764
+ spawn('bash', [path.join(ROOT, 'setup.sh'), '--tool', 'cursor', ...args]);
3765
+ },
3766
+ },
3767
+ update: {
3768
+ desc: 'Update Wayfind to latest version',
3769
+ run: (args) => {
3770
+ // Step 1: Pull latest from npm
3771
+ const skipNpm = args.includes('--skip-npm');
3772
+ if (!skipNpm) {
3773
+ console.log('Updating wayfind from npm...');
3774
+ const npmResult = spawnSync('npm', ['update', '-g', 'wayfind'], {
3775
+ stdio: 'inherit',
3776
+ });
3777
+ if (npmResult.error || (npmResult.status && npmResult.status !== 0)) {
3778
+ console.error('npm update failed. Try running: npm update -g wayfind');
3779
+ console.error('Then re-run: wayfind update --skip-npm');
3780
+ process.exit(1);
3781
+ }
3782
+ }
3783
+
3784
+ // Step 2: Re-run setup in update mode with the (now current) version
3785
+ const versionFile = path.join(HOME, '.claude', 'team-context', '.wayfind-version');
3786
+ let oldVersion = '';
3787
+ try {
3788
+ oldVersion = fs.readFileSync(versionFile, 'utf8').trim();
3789
+ } catch (e) {
3790
+ // No version file — fresh install or pre-version install
3791
+ }
3792
+ let newVersion = '';
3793
+ try {
3794
+ const pkg = require(path.join(ROOT, 'package.json'));
3795
+ newVersion = pkg.version;
3796
+ } catch (e) {
3797
+ // Can't read package.json
3798
+ }
3799
+ const env = { ...process.env };
3800
+ if (oldVersion) env.WAYFIND_OLD_VERSION = oldVersion;
3801
+ if (newVersion) env.WAYFIND_NEW_VERSION = newVersion;
3802
+ const tool = args.includes('--tool') ? args : ['--tool', 'claude-code', ...args];
3803
+ const filteredArgs = tool.filter(a => a !== '--skip-npm');
3804
+ const result = spawnSync('bash', [path.join(ROOT, 'setup.sh'), '--update', ...filteredArgs], {
3805
+ stdio: 'inherit',
3806
+ env,
3807
+ });
3808
+ if (result.error) {
3809
+ console.error(`Error: ${result.error.message}`);
3810
+ process.exit(1);
3811
+ }
3812
+ if (result.status && result.status !== 0) {
3813
+ process.exit(result.status);
3814
+ }
3815
+
3816
+ // Step 3: Update Wayfind container if one is running
3817
+ const dockerCheck = spawnSync('docker', ['compose', 'version'], { stdio: 'pipe' });
3818
+ if (!dockerCheck.error && dockerCheck.status === 0) {
3819
+ const psResult = spawnSync('docker', ['ps', '--filter', 'name=wayfind', '--format', '{{.Names}}'], { stdio: 'pipe' });
3820
+ const containers = (psResult.stdout || '').toString().trim();
3821
+ if (containers && containers.split('\n').some(c => c === 'wayfind')) {
3822
+ console.log('\nWayfind container detected — pulling latest image...');
3823
+
3824
+ // Find compose file directory: check container labels, then known paths
3825
+ let composeDir = '';
3826
+ const inspectResult = spawnSync('docker', ['inspect', 'wayfind', '--format', '{{index .Config.Labels "com.docker.compose.project.working_dir"}}'], { stdio: 'pipe' });
3827
+ const labelDir = (inspectResult.stdout || '').toString().trim();
3828
+ if (labelDir && fs.existsSync(path.join(labelDir, 'docker-compose.yml'))) {
3829
+ composeDir = labelDir;
3830
+ } else {
3831
+ const teamCtx = process.env.TEAM_CONTEXT_TEAM_CONTEXT_DIR || '';
3832
+ const candidates = [
3833
+ teamCtx ? path.join(teamCtx, 'deploy') : '',
3834
+ process.cwd(),
3835
+ path.join(HOME, 'team-context', 'deploy'),
3836
+ ].filter(Boolean);
3837
+ for (const dir of candidates) {
3838
+ if (fs.existsSync(path.join(dir, 'docker-compose.yml'))) {
3839
+ composeDir = dir;
3840
+ break;
3841
+ }
3842
+ }
3843
+ }
3844
+
3845
+ if (composeDir) {
3846
+ console.log(`Using compose file in: ${composeDir}`);
3847
+ const pullResult = spawnSync('docker', ['compose', 'pull'], { cwd: composeDir, stdio: 'inherit' });
3848
+ if (!pullResult.error && pullResult.status === 0) {
3849
+ console.log('Recreating container with new image...');
3850
+ spawnSync('docker', ['compose', 'up', '-d'], { cwd: composeDir, stdio: 'inherit' });
3851
+ console.log('Container updated.');
3852
+ } else {
3853
+ console.error('Docker pull failed — container not updated.');
3854
+ }
3855
+ } else {
3856
+ console.log('Could not locate docker-compose.yml for the Wayfind container.');
3857
+ console.log('Run "docker compose pull && docker compose up -d" manually in your deploy directory.');
3858
+ }
3859
+ }
3860
+ }
3861
+ },
3862
+ },
3863
+ digest: {
3864
+ desc: 'Generate persona-specific digests from signals + journals',
3865
+ run: (args) => runDigest(args),
3866
+ },
3867
+ journal: {
3868
+ desc: 'Journal management (summary, migrate, sync)',
3869
+ run: (args) => runJournal(args),
3870
+ },
3871
+ personas: {
3872
+ desc: 'List, add, or remove personas',
3873
+ run: (args) => runPersonas(args),
3874
+ },
3875
+ team: {
3876
+ desc: 'Manage your team (create, join, status)',
3877
+ run: (args) => runTeam(args),
3878
+ },
3879
+ whoami: {
3880
+ desc: 'Show or set up your Wayfind profile and personas',
3881
+ run: (args) => runWhoami(args),
3882
+ },
3883
+ autopilot: {
3884
+ desc: 'Show or configure persona autopilot mode',
3885
+ run: (args) => runAutopilot(args),
3886
+ },
3887
+ members: {
3888
+ desc: 'Show team members with versions and activity',
3889
+ run: (args) => runMembers(args),
3890
+ },
3891
+ 'check-version': {
3892
+ desc: 'Check if installed version meets team minimum (used by hooks)',
3893
+ run: () => runCheckVersion(),
3894
+ },
3895
+ doctor: {
3896
+ desc: 'Check your Wayfind installation for issues',
3897
+ run: (args) => {
3898
+ spawn('bash', [path.join(ROOT, 'doctor.sh'), ...args]);
3899
+ },
3900
+ },
3901
+ version: {
3902
+ desc: 'Print installed Wayfind version',
3903
+ run: () => {
3904
+ const versionFile = path.join(HOME, '.claude', 'team-context', '.wayfind-version');
3905
+ try {
3906
+ const version = fs.readFileSync(versionFile, 'utf8').trim();
3907
+ console.log(`Wayfind v${version}`);
3908
+ } catch (err) {
3909
+ // Fall back to package.json version if no installed version file
3910
+ try {
3911
+ const pkg = require(path.join(ROOT, 'package.json'));
3912
+ console.log(`Wayfind v${pkg.version} (from package.json)`);
3913
+ } catch (e) {
3914
+ console.error('Version unknown (no .wayfind-version file found)');
3915
+ process.exit(1);
3916
+ }
3917
+ }
3918
+ },
3919
+ },
3920
+ pull: {
3921
+ desc: 'Pull signals from a channel (see "wayfind signals" for available)',
3922
+ run: (args) => runPull(args),
3923
+ },
3924
+ status: {
3925
+ desc: 'Show cross-project status (or rebuild Active Projects table)',
3926
+ run: (args) => runStatus(args),
3927
+ },
3928
+ signals: {
3929
+ desc: 'Show configured signal channels and last pull times',
3930
+ run: () => runSignals(),
3931
+ },
3932
+ bot: {
3933
+ desc: 'Start the Wayfind Slack bot for decision trail queries',
3934
+ run: (args) => runBot(args),
3935
+ },
3936
+ context: {
3937
+ desc: 'Manage shared team context (init, sync, show)',
3938
+ run: (args) => runContext(args),
3939
+ },
3940
+ prompts: {
3941
+ desc: 'List or show shared team prompts',
3942
+ run: (args) => runPrompts(args),
3943
+ },
3944
+ deploy: {
3945
+ desc: 'Scaffold Docker deployment in your team context repo',
3946
+ run: (args) => runDeploy(args),
3947
+ },
3948
+ onboard: {
3949
+ desc: 'Generate an onboarding context pack for a repo',
3950
+ run: (args) => runOnboard(args),
3951
+ },
3952
+ reindex: {
3953
+ desc: 'Index all signal sources (journals + conversations)',
3954
+ run: (args) => runReindex(args),
3955
+ },
3956
+ 'index-journals': {
3957
+ desc: 'Index journal entries into the content store',
3958
+ run: (args) => runIndexJournals(args),
3959
+ },
3960
+ 'index-conversations': {
3961
+ desc: 'Extract and index decision points from Claude Code transcripts',
3962
+ run: (args) => runIndexConversations(args),
3963
+ },
3964
+ 'search-journals': {
3965
+ desc: 'Search indexed entries (journals + conversations, semantic or full-text)',
3966
+ run: (args) => runSearchJournals(args),
3967
+ },
3968
+ insights: {
3969
+ desc: 'Show insights from indexed journal data',
3970
+ run: (args) => runInsights(args),
3971
+ },
3972
+ quality: {
3973
+ desc: 'View your decision quality profile and elicitation focus',
3974
+ run: (args) => runQuality(args),
3975
+ },
3976
+ 'sync-public': {
3977
+ desc: 'Sync code to the public usewayfind/wayfind repo',
3978
+ run: () => {
3979
+ const tmpDir = path.join(os.tmpdir(), 'wayfind-public-sync');
3980
+ const publicRepo = 'https://github.com/usewayfind/wayfind.git';
3981
+
3982
+ // Clone or pull public repo
3983
+ if (fs.existsSync(tmpDir)) {
3984
+ console.log('Updating existing clone...');
3985
+ const pullResult = spawnSync('git', ['pull', '--rebase'], { cwd: tmpDir, stdio: 'inherit' });
3986
+ if (pullResult.status !== 0) {
3987
+ console.error('git pull failed — try removing ' + tmpDir);
3988
+ process.exit(1);
3989
+ }
3990
+ } else {
3991
+ console.log('Cloning usewayfind/wayfind...');
3992
+ const cloneResult = spawnSync('git', ['clone', publicRepo, tmpDir], { stdio: 'inherit' });
3993
+ if (cloneResult.status !== 0) {
3994
+ console.error('Clone failed — check your GitHub access to usewayfind/wayfind');
3995
+ process.exit(1);
3996
+ }
3997
+ }
3998
+
3999
+ // Files and directories to sync
4000
+ const syncItems = [
4001
+ 'bin/', 'templates/', 'specializations/', 'tests/', 'simulation/',
4002
+ 'Dockerfile', 'package.json', 'setup.sh', 'install.sh', 'uninstall.sh',
4003
+ 'doctor.sh', 'journal-summary.sh', 'BOOTSTRAP_PROMPT.md',
4004
+ ];
4005
+
4006
+ // Also sync public-staging docs if they exist
4007
+ const publicDocsDir = path.join(ROOT, 'public-staging', 'docs');
4008
+
4009
+ console.log('Syncing files...');
4010
+ for (const item of syncItems) {
4011
+ const isDir = item.endsWith('/');
4012
+ const name = item.replace(/\/$/, '');
4013
+ const src = path.join(ROOT, name);
4014
+ if (!fs.existsSync(src)) continue;
4015
+ if (isDir) {
4016
+ // rsync without trailing slash on source copies the directory itself into dest
4017
+ const result = spawnSync('rsync', ['-a', '--delete', src, tmpDir + '/'], { stdio: 'inherit' });
4018
+ if (result.status !== 0) console.error(`Failed to sync ${item}`);
4019
+ } else {
4020
+ const result = spawnSync('cp', [src, path.join(tmpDir, name)], { stdio: 'inherit' });
4021
+ if (result.status !== 0) console.error(`Failed to sync ${item}`);
4022
+ }
4023
+ }
4024
+
4025
+ // Sync public-staging docs to docs/
4026
+ if (fs.existsSync(publicDocsDir)) {
4027
+ spawnSync('rsync', ['-a', '--delete', publicDocsDir + '/', path.join(tmpDir, 'docs') + '/'], { stdio: 'inherit' });
4028
+ }
4029
+
4030
+ // Show what changed
4031
+ const diffResult = spawnSync('git', ['status', '--short'], { cwd: tmpDir, stdio: 'pipe' });
4032
+ const changes = (diffResult.stdout || '').toString().trim();
4033
+
4034
+ if (!changes) {
4035
+ console.log('Public repo is already up to date.');
4036
+ return;
4037
+ }
4038
+
4039
+ console.log('\nChanges to push:');
4040
+ console.log(changes);
4041
+
4042
+ // Get version for commit message
4043
+ let version = 'unknown';
4044
+ try { version = require(path.join(ROOT, 'package.json')).version; } catch {}
4045
+
4046
+ // Commit and push
4047
+ spawnSync('git', ['add', '-A'], { cwd: tmpDir, stdio: 'inherit' });
4048
+ const commitResult = spawnSync('git', ['commit', '-m', `Sync v${version} from private repo`], { cwd: tmpDir, stdio: 'inherit' });
4049
+ if (commitResult.status !== 0) {
4050
+ console.error('Commit failed');
4051
+ process.exit(1);
4052
+ }
4053
+
4054
+ console.log('Pushing to usewayfind/wayfind...');
4055
+ // Use GH_TOKEN to ensure correct account (gh multi-account may route wrong)
4056
+ const tokenResult = spawnSync('gh', ['auth', 'token'], { stdio: 'pipe' });
4057
+ const pushEnv = { ...process.env };
4058
+ if (tokenResult.stdout) pushEnv.GH_TOKEN = tokenResult.stdout.toString().trim();
4059
+ const pushResult = spawnSync('git', ['push'], { cwd: tmpDir, stdio: 'inherit', env: pushEnv });
4060
+ if (pushResult.status !== 0) {
4061
+ console.error('Push failed — check your access to usewayfind/wayfind');
4062
+ process.exit(1);
4063
+ }
4064
+
4065
+ console.log(`\nSynced v${version} to usewayfind/wayfind`);
4066
+ console.log('GitHub Actions will publish npm + Docker automatically.');
4067
+ },
4068
+ },
4069
+ help: {
4070
+ desc: 'Show this help message',
4071
+ run: () => showHelp(),
4072
+ },
4073
+ };
4074
+
4075
+ function showHelp() {
4076
+ console.log('');
4077
+ console.log('Wayfind — Team decision trail for AI-assisted development');
4078
+ console.log('');
4079
+ console.log('Usage: wayfind <command> [options]');
4080
+ console.log('');
4081
+ console.log('Commands:');
4082
+ for (const [name, cmd] of Object.entries(COMMANDS)) {
4083
+ console.log(` ${name.padEnd(16)} ${cmd.desc}`);
4084
+ }
4085
+ console.log('');
4086
+ console.log('Getting started:');
4087
+ console.log(' npx wayfind init Install for Claude Code');
4088
+ console.log(' npx wayfind init-cursor Install for Cursor');
4089
+ console.log('');
4090
+ console.log('In a Claude Code session:');
4091
+ console.log(' /init-memory Set up memory for current repo');
4092
+ console.log(' /init-team Set up team context (journals, digests, Notion)');
4093
+ console.log(' /journal View your session journal digest');
4094
+ console.log(' /doctor Check installation health');
4095
+ console.log('');
4096
+ console.log('Team setup:');
4097
+ console.log(' wayfind team create Create a new team');
4098
+ console.log(' wayfind team join <id> Join an existing team');
4099
+ console.log(' wayfind team status Show current team info');
4100
+ console.log(' wayfind whoami Show your profile');
4101
+ console.log(' wayfind whoami --setup Set up your profile and personas');
4102
+ console.log('');
4103
+ console.log('Personas:');
4104
+ console.log(' wayfind personas List configured personas');
4105
+ console.log(' wayfind personas --add <id> <name> [description]');
4106
+ console.log(' wayfind personas --remove <id>');
4107
+ console.log(' wayfind personas --reset Restore default personas');
4108
+ console.log('');
4109
+ console.log('Autopilot:');
4110
+ console.log(' wayfind autopilot status Which personas are human vs. autopilot');
4111
+ console.log(' wayfind autopilot enable <persona> Enable autopilot for a persona');
4112
+ console.log(' wayfind autopilot disable <persona> Disable autopilot for a persona');
4113
+ console.log('');
4114
+ console.log('Digests:');
4115
+ console.log(' wayfind digest Generate all persona digests');
4116
+ console.log(' wayfind digest --persona engineering Generate one persona only');
4117
+ console.log(' wayfind digest --deliver Generate + post to Slack');
4118
+ console.log(' wayfind digest --since 2026-02-24 Override lookback period');
4119
+ console.log(' wayfind digest --configure Set up LLM + Slack config');
4120
+ console.log(' wayfind journal [--last-week] Plain-text journal summary');
4121
+ console.log(' wayfind journal migrate [--dry-run] Rename journals to YYYY-MM-DD-{author}.md');
4122
+ console.log(' wayfind journal sync [--since DATE] Copy authored journals to team-context repo');
4123
+ console.log('');
4124
+ console.log('Signal channels:');
4125
+ console.log(' wayfind pull github Pull GitHub signals');
4126
+ console.log(' wayfind pull github --configure Configure GitHub connector');
4127
+ console.log(' wayfind pull github --since YYYY-MM-DD');
4128
+ console.log(' wayfind pull github --add-repo owner/repo');
4129
+ console.log(' wayfind pull github --remove-repo owner/repo');
4130
+ console.log(' wayfind pull intercom Pull Intercom signals');
4131
+ console.log(' wayfind pull intercom --configure Configure Intercom connector');
4132
+ console.log(' wayfind pull --all Pull all configured channels');
4133
+ console.log(' wayfind signals Show configured channels');
4134
+ console.log('');
4135
+ console.log('Prompts:');
4136
+ console.log(' wayfind prompts List shared team prompts');
4137
+ console.log(' wayfind prompts <name> Show a specific prompt');
4138
+ console.log('');
4139
+ console.log('Bot:');
4140
+ console.log(' wayfind bot Start the Slack bot (Socket Mode)');
4141
+ console.log(' wayfind bot --configure Set up Slack app tokens + LLM config');
4142
+ console.log('');
4143
+ console.log('Deployment:');
4144
+ console.log(' wayfind deploy init Scaffold Docker deployment files');
4145
+ console.log(' wayfind deploy status Check deployment configuration');
4146
+ console.log('');
4147
+ console.log('Members:');
4148
+ console.log(' wayfind members Show team members with versions');
4149
+ console.log(' wayfind members --json Machine-readable output');
4150
+ console.log(' wayfind members --set-min-version X.Y.Z Set minimum required version');
4151
+ console.log('');
4152
+ console.log('Status:');
4153
+ console.log(' wayfind status Print cross-project status table');
4154
+ console.log(' wayfind status --write Rebuild Active Projects in global-state.md');
4155
+ console.log(' wayfind status --json Machine-readable output');
4156
+ console.log(' wayfind status --quiet Suppress output (for hooks)');
4157
+ console.log('');
4158
+ console.log('Publishing:');
4159
+ console.log(' wayfind sync-public Sync code to usewayfind/wayfind (triggers npm + Docker publish)');
4160
+ console.log('');
4161
+ console.log('Content store:');
4162
+ console.log(' wayfind index-journals Index journal entries');
4163
+ console.log(' wayfind index-journals --dir <path> Custom journal directory');
4164
+ console.log(' wayfind index-journals --no-embeddings Skip embedding generation');
4165
+ console.log(' wayfind search-journals <query> Semantic search (needs OPENAI_API_KEY)');
4166
+ console.log(' wayfind search-journals <query> --text Full-text search (no API key)');
4167
+ console.log(' wayfind search-journals <query> --repo wayfind --since 2026-02-01');
4168
+ console.log(' wayfind insights Show journal insights');
4169
+ console.log(' wayfind insights --json JSON output');
4170
+ console.log('');
4171
+ }
4172
+
4173
+ function spawn(cmd, args) {
4174
+ const result = spawnSync(cmd, args, {
4175
+ stdio: 'inherit',
4176
+ env: { ...process.env },
4177
+ });
4178
+ if (result.error) {
4179
+ console.error(`Error: ${result.error.message}`);
4180
+ process.exit(1);
4181
+ }
4182
+ if (result.signal) {
4183
+ process.kill(process.pid, result.signal);
4184
+ }
4185
+ process.exit(result.status == null ? 1 : result.status);
4186
+ }
4187
+
4188
+ // --- Main ---
4189
+
4190
+ const args = process.argv.slice(2);
4191
+ const command = args[0] || 'help';
4192
+ const commandArgs = args.slice(1);
4193
+
4194
+ async function main() {
4195
+ telemetry.capture('command_run', { command }, CLI_USER);
4196
+ if (COMMANDS[command]) {
4197
+ await COMMANDS[command].run(commandArgs);
4198
+ await telemetry.flush();
4199
+ } else {
4200
+ console.error(`Unknown command: ${command}`);
4201
+ console.error('Run "wayfind help" for available commands.');
4202
+ process.exit(1);
4203
+ }
4204
+ }
4205
+
4206
+ main().catch((err) => {
4207
+ console.error(`Error: ${err.message}`);
4208
+ process.exit(1);
4209
+ });