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
package/bin/digest.js ADDED
@@ -0,0 +1,813 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const readline = require('readline');
6
+ const llm = require('./connectors/llm');
7
+ const contentStore = require('./content-store');
8
+ const telemetry = require('./telemetry');
9
+
10
+ const HOME = process.env.HOME || process.env.USERPROFILE;
11
+ const WAYFIND_DIR = path.join(HOME, '.claude', 'team-context');
12
+ const ENV_FILE = path.join(WAYFIND_DIR, '.env');
13
+
14
+ // Build regex for content-level filtering of excluded repos
15
+ const EXCLUDE_REPOS_RAW = (process.env.TEAM_CONTEXT_EXCLUDE_REPOS || '')
16
+ .split(',').map(r => r.trim()).filter(Boolean);
17
+
18
+ function buildExcludePattern() {
19
+ if (EXCLUDE_REPOS_RAW.length === 0) return null;
20
+ // Match repo names as whole words (case-insensitive)
21
+ const escaped = EXCLUDE_REPOS_RAW.map(r => r.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
22
+ return new RegExp(`\\b(${escaped.join('|')})\\b`, 'i');
23
+ }
24
+
25
+ const EXCLUDE_CONTENT_RE = buildExcludePattern();
26
+
27
+ /**
28
+ * Filter assembled content sections by removing any section whose body
29
+ * mentions an excluded repo. Sections are separated by \n\n---\n\n.
30
+ */
31
+ function filterExcludedContent(content) {
32
+ if (!EXCLUDE_CONTENT_RE || !content) return content;
33
+ return content
34
+ .split('\n\n---\n\n')
35
+ .filter(section => !EXCLUDE_CONTENT_RE.test(section))
36
+ .join('\n\n---\n\n');
37
+ }
38
+
39
+ // ── Env file helpers ────────────────────────────────────────────────────────
40
+
41
+ /**
42
+ * Save a key=value pair to ~/.claude/team-context/.env.
43
+ * Appends or updates the key. Creates the file if missing.
44
+ */
45
+ function saveEnvKey(key, value) {
46
+ fs.mkdirSync(WAYFIND_DIR, { recursive: true });
47
+ let lines = [];
48
+ if (fs.existsSync(ENV_FILE)) {
49
+ lines = fs.readFileSync(ENV_FILE, 'utf8').split('\n');
50
+ }
51
+ const prefix = `${key}=`;
52
+ const idx = lines.findIndex((l) => l.startsWith(prefix));
53
+ const entry = `${key}=${value}`;
54
+ if (idx !== -1) {
55
+ lines[idx] = entry;
56
+ } else {
57
+ lines.push(entry);
58
+ }
59
+ fs.writeFileSync(ENV_FILE, lines.filter((l) => l !== '').join('\n') + '\n', 'utf8');
60
+ console.log(`Saved to ${ENV_FILE}`);
61
+ }
62
+
63
+ // ── Helpers ─────────────────────────────────────────────────────────────────
64
+
65
+ function ask(question) {
66
+ const rl = readline.createInterface({
67
+ input: process.stdin,
68
+ output: process.stdout,
69
+ });
70
+ return new Promise((resolve) => {
71
+ rl.question(question, (answer) => {
72
+ rl.close();
73
+ resolve(answer.trim());
74
+ });
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Extract a YYYY-MM-DD date from a filename.
80
+ * Matches the first occurrence of a date pattern in the basename.
81
+ * @param {string} filename
82
+ * @returns {string|null}
83
+ */
84
+ function extractDate(filename) {
85
+ const base = path.basename(filename);
86
+ const match = base.match(/(\d{4}-\d{2}-\d{2})/);
87
+ return match ? match[1] : null;
88
+ }
89
+
90
+ /**
91
+ * Find files in a directory matching a date suffix pattern.
92
+ * Files must contain a YYYY-MM-DD date >= sinceDate and end with the given suffix.
93
+ * @param {string} dir - Directory to search
94
+ * @param {string} sinceDate - Minimum date (YYYY-MM-DD, inclusive)
95
+ * @param {string} suffix - File suffix to match (e.g. '-summary.md')
96
+ * @returns {string[]} Array of absolute file paths
97
+ */
98
+ function findFilesWithDate(dir, sinceDate, suffix) {
99
+ const results = [];
100
+ if (!fs.existsSync(dir)) return results;
101
+
102
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
103
+ for (const entry of entries) {
104
+ const fullPath = path.join(dir, entry.name);
105
+ if (entry.isDirectory()) {
106
+ results.push(...findFilesWithDate(fullPath, sinceDate, suffix));
107
+ } else if (entry.isFile() && entry.name.endsWith(suffix)) {
108
+ const date = extractDate(entry.name);
109
+ if (date && date >= sinceDate) {
110
+ results.push(fullPath);
111
+ }
112
+ }
113
+ }
114
+ return results;
115
+ }
116
+
117
+ /**
118
+ * Find files in a directory (non-recursive) matching a date suffix pattern.
119
+ * @param {string} dir - Directory to search (top level only)
120
+ * @param {string} sinceDate - Minimum date (YYYY-MM-DD, inclusive)
121
+ * @param {string} suffix - File suffix to match (e.g. '-summary.md')
122
+ * @returns {string[]} Array of absolute file paths
123
+ */
124
+ function findFilesShallow(dir, sinceDate, suffix) {
125
+ const results = [];
126
+ if (!fs.existsSync(dir)) return results;
127
+
128
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
129
+ for (const entry of entries) {
130
+ if (entry.isFile() && entry.name.endsWith(suffix)) {
131
+ const date = extractDate(entry.name);
132
+ if (date && date >= sinceDate) {
133
+ results.push(path.join(dir, entry.name));
134
+ }
135
+ }
136
+ }
137
+ return results;
138
+ }
139
+
140
+ /**
141
+ * Walk owner/repo subdirectories looking for YYYY-MM-DD.md files.
142
+ * @param {string} channelDir - Channel directory (e.g. signals/github/)
143
+ * @param {string} sinceDate - Minimum date (YYYY-MM-DD, inclusive)
144
+ * @returns {string[]} Array of absolute file paths
145
+ */
146
+ function findRepoFiles(channelDir, sinceDate) {
147
+ const results = [];
148
+ if (!fs.existsSync(channelDir)) return results;
149
+
150
+ const owners = fs.readdirSync(channelDir, { withFileTypes: true })
151
+ .filter((d) => d.isDirectory());
152
+
153
+ for (const owner of owners) {
154
+ const ownerDir = path.join(channelDir, owner.name);
155
+ const repos = fs.readdirSync(ownerDir, { withFileTypes: true })
156
+ .filter((d) => d.isDirectory());
157
+
158
+ for (const repo of repos) {
159
+ const repoDir = path.join(ownerDir, repo.name);
160
+ const files = fs.readdirSync(repoDir, { withFileTypes: true })
161
+ .filter((f) => f.isFile() && f.name.endsWith('.md'));
162
+
163
+ for (const file of files) {
164
+ const date = extractDate(file.name);
165
+ if (date && date >= sinceDate) {
166
+ results.push(path.join(repoDir, file.name));
167
+ }
168
+ }
169
+ }
170
+ }
171
+ return results;
172
+ }
173
+
174
+ /**
175
+ * Get today's date as YYYY-MM-DD.
176
+ */
177
+ function today() {
178
+ return new Date().toISOString().slice(0, 10);
179
+ }
180
+
181
+ // ── Store-based collection ──────────────────────────────────────────────────
182
+
183
+ /**
184
+ * Collect digest content from the indexed content store.
185
+ * Queries the store for all entries in the date range and retrieves full content.
186
+ * Separates entries into journals/conversations and signals.
187
+ * @param {string} sinceDate - Minimum date (YYYY-MM-DD, inclusive)
188
+ * @param {Object} [options]
189
+ * @param {string} [options.storePath] - Content store directory
190
+ * @param {string} [options.journalDir] - Journal directory
191
+ * @param {string} [options.signalsDir] - Signals directory
192
+ * @returns {{ journals: string, signals: string, entryCount: number }}
193
+ */
194
+ function collectFromStore(sinceDate, options = {}) {
195
+ const storePath = options.storePath || contentStore.DEFAULT_STORE_PATH;
196
+ const journalDir = options.journalDir || contentStore.DEFAULT_JOURNAL_DIR;
197
+ const signalsDir = options.signalsDir || contentStore.DEFAULT_SIGNALS_DIR;
198
+
199
+ const entries = contentStore.queryMetadata({
200
+ since: sinceDate,
201
+ until: today(),
202
+ storePath,
203
+ });
204
+
205
+ if (entries.length === 0) {
206
+ return { journals: '', signals: '', entryCount: 0 };
207
+ }
208
+
209
+ const journalParts = [];
210
+ const signalParts = [];
211
+
212
+ for (const { id, entry } of entries) {
213
+ const content = contentStore.getEntryContent(id, { storePath, journalDir, signalsDir });
214
+ if (!content) continue;
215
+
216
+ // Format with metadata header
217
+ const source = entry.source || 'journal';
218
+ const author = entry.user ? `Author: ${entry.user}` : '';
219
+ const sourceMarker = source === 'conversation' ? ' [from conversation]' : '';
220
+ const header = `### ${entry.date} — ${entry.repo}${entry.title ? ' — ' + entry.title : ''}${sourceMarker}`;
221
+ const meta = author ? `${header}\n${author}\n` : `${header}\n`;
222
+ const formatted = `${meta}\n${content}`;
223
+
224
+ if (source === 'signal') {
225
+ signalParts.push(formatted);
226
+ } else {
227
+ journalParts.push(formatted);
228
+ }
229
+ }
230
+
231
+ return {
232
+ journals: journalParts.join('\n\n---\n\n'),
233
+ signals: signalParts.join('\n\n---\n\n'),
234
+ entryCount: entries.length,
235
+ };
236
+ }
237
+
238
+ // ── Public API ──────────────────────────────────────────────────────────────
239
+
240
+ /**
241
+ * Collect signal data from all channels since the given date.
242
+ * Prefers rollup summaries (*-summary.md) over per-repo files to manage token budget.
243
+ * @param {string} sinceDate - Minimum date (YYYY-MM-DD, inclusive)
244
+ * @param {string} [signalsDir] - Override signals directory
245
+ * @returns {string} Concatenated markdown with channel headers
246
+ */
247
+ function collectSignals(sinceDate, signalsDir) {
248
+ signalsDir = signalsDir || path.join(HOME, '.claude', 'team-context', 'signals');
249
+ if (!fs.existsSync(signalsDir)) return '';
250
+
251
+ const sections = [];
252
+ const channels = fs.readdirSync(signalsDir, { withFileTypes: true })
253
+ .filter((d) => d.isDirectory())
254
+ .map((d) => d.name);
255
+
256
+ for (const channel of channels) {
257
+ const channelDir = path.join(signalsDir, channel);
258
+
259
+ // Prefer summary files at the channel root (non-recursive) over per-repo files
260
+ const summaries = findFilesShallow(channelDir, sinceDate, '-summary.md');
261
+ if (summaries.length > 0) {
262
+ const content = summaries
263
+ .sort()
264
+ .map((f) => fs.readFileSync(f, 'utf8'))
265
+ .join('\n\n');
266
+ sections.push(`## ${channel} signals\n\n${content}`);
267
+ } else {
268
+ // Fall back to per-repo files
269
+ const repoFiles = findRepoFiles(channelDir, sinceDate);
270
+ if (repoFiles.length > 0) {
271
+ const content = repoFiles
272
+ .sort()
273
+ .map((f) => fs.readFileSync(f, 'utf8'))
274
+ .join('\n\n');
275
+ sections.push(`## ${channel} signals\n\n${content}`);
276
+ }
277
+ }
278
+ }
279
+
280
+ return sections.join('\n\n---\n\n');
281
+ }
282
+
283
+ /**
284
+ * Collect journal entries since the given date.
285
+ * @param {string} sinceDate - Minimum date (YYYY-MM-DD, inclusive)
286
+ * @param {string} [journalDir] - Override journal directory
287
+ * @returns {string} Concatenated journal content
288
+ */
289
+ function collectJournals(sinceDate, journalDir) {
290
+ journalDir = journalDir || path.join(HOME, '.claude', 'memory', 'journal');
291
+ if (!fs.existsSync(journalDir)) return '';
292
+
293
+ const files = fs.readdirSync(journalDir, { withFileTypes: true })
294
+ .filter((f) => f.isFile() && f.name.endsWith('.md'))
295
+ .filter((f) => {
296
+ const date = extractDate(f.name);
297
+ return date && date >= sinceDate;
298
+ })
299
+ .sort((a, b) => a.name.localeCompare(b.name));
300
+
301
+ if (files.length === 0) return '';
302
+
303
+ return files
304
+ .map((f) => fs.readFileSync(path.join(journalDir, f.name), 'utf8'))
305
+ .join('\n\n---\n\n');
306
+ }
307
+
308
+ /**
309
+ * Load team context files (product.md, etc.) from the team-context directory.
310
+ * Returns a combined string, or empty string if not available.
311
+ */
312
+ function loadTeamContext(teamContextDir) {
313
+ if (!teamContextDir) return '';
314
+ const contextDir = path.join(teamContextDir, 'context');
315
+ if (!fs.existsSync(contextDir)) return '';
316
+
317
+ const parts = [];
318
+ const files = ['product.md', 'engineering.md', 'architecture.md'];
319
+ for (const file of files) {
320
+ const fp = path.join(contextDir, file);
321
+ try {
322
+ const content = fs.readFileSync(fp, 'utf8').trim();
323
+ if (content) parts.push(content);
324
+ } catch { /* skip missing files */ }
325
+ }
326
+ return parts.join('\n\n');
327
+ }
328
+
329
+ /**
330
+ * Load team member profiles and build an author→role map.
331
+ * Returns a formatted string like "- greg: CTO/Founder (engineering, strategy)"
332
+ */
333
+ function loadTeamMembers(teamContextDir) {
334
+ if (!teamContextDir) return '';
335
+ const membersDir = path.join(teamContextDir, 'members');
336
+ if (!fs.existsSync(membersDir)) return '';
337
+
338
+ const lines = [];
339
+ try {
340
+ const files = fs.readdirSync(membersDir).filter(f => f.endsWith('.json'));
341
+ for (const file of files) {
342
+ try {
343
+ const member = JSON.parse(fs.readFileSync(path.join(membersDir, file), 'utf8'));
344
+ const name = member.name || file.replace('.json', '');
345
+ const personas = (member.personas || []).join(', ');
346
+ const role = member.role || '';
347
+ const desc = [role, personas ? `(${personas})` : ''].filter(Boolean).join(' ');
348
+ lines.push(`- ${name}: ${desc || 'team member'}`);
349
+ } catch { /* skip invalid files */ }
350
+ }
351
+ } catch { /* skip if unreadable */ }
352
+ return lines.join('\n');
353
+ }
354
+
355
+ /**
356
+ * Load the most recent previous digest for dedup.
357
+ * Returns the digest content or empty string.
358
+ */
359
+ function loadPreviousDigest(personaId, currentDate) {
360
+ const digestDir = path.join(HOME, '.claude', 'team-context', 'digests', personaId);
361
+ if (!fs.existsSync(digestDir)) return '';
362
+
363
+ try {
364
+ const files = fs.readdirSync(digestDir)
365
+ .filter(f => f.endsWith('.md') && f < `${currentDate}.md`)
366
+ .sort()
367
+ .reverse();
368
+ if (files.length === 0) return '';
369
+ return fs.readFileSync(path.join(digestDir, files[0]), 'utf8').trim();
370
+ } catch { return ''; }
371
+ }
372
+
373
+ // ── Feedback-driven learning ─────────────────────────────────────────────────
374
+
375
+ const POSITIVE_REACTIONS = new Set([
376
+ 'rocket', 'fire', 'tada', 'heart', '+1', 'thumbsup',
377
+ '100', 'star', 'raised_hands', 'clap', 'pray', 'muscle',
378
+ 'white_check_mark', 'heavy_check_mark', 'star-struck', 'boom',
379
+ ]);
380
+
381
+ const NEGATIVE_REACTIONS = new Set([
382
+ '-1', 'thumbsdown', 'thinking_face', 'confused',
383
+ 'disappointed', 'face_with_rolling_eyes', 'x', 'no_entry_sign',
384
+ ]);
385
+
386
+ /**
387
+ * Build a feedback context section for the digest prompt.
388
+ * Summarizes recent team reactions and text feedback so the LLM
389
+ * can adapt what it surfaces.
390
+ *
391
+ * @param {string} personaId - Persona to filter feedback for
392
+ * @param {Object} [options]
393
+ * @param {string} [options.storePath] - Content store directory
394
+ * @param {number} [options.lookbackDays] - Days of feedback to consider (default: 14)
395
+ * @param {number} [options.maxChars] - Max output chars (default: 500)
396
+ * @returns {string} - Feedback section text, or empty string if no feedback
397
+ */
398
+ function buildFeedbackContext(personaId, options = {}) {
399
+ const lookbackDays = options.lookbackDays || 14;
400
+ const maxChars = options.maxChars || 500;
401
+
402
+ const since = new Date();
403
+ since.setDate(since.getDate() - lookbackDays);
404
+ const sinceStr = since.toISOString().slice(0, 10);
405
+
406
+ const feedback = contentStore.getDigestFeedback({
407
+ storePath: options.storePath,
408
+ since: sinceStr,
409
+ });
410
+
411
+ if (!feedback || feedback.length === 0) return '';
412
+
413
+ // Filter to matching persona (or include all if persona not specified in feedback)
414
+ const relevant = feedback.filter(f => !f.persona || f.persona === personaId);
415
+ if (relevant.length === 0) return '';
416
+
417
+ // Tally positive/negative reactions
418
+ let positiveTotal = 0;
419
+ let negativeTotal = 0;
420
+ const positiveEmoji = {};
421
+ const negativeEmoji = {};
422
+
423
+ for (const f of relevant) {
424
+ for (const [emoji, count] of Object.entries(f.reactions || {})) {
425
+ if (POSITIVE_REACTIONS.has(emoji)) {
426
+ positiveTotal += count;
427
+ positiveEmoji[emoji] = (positiveEmoji[emoji] || 0) + count;
428
+ } else if (NEGATIVE_REACTIONS.has(emoji)) {
429
+ negativeTotal += count;
430
+ negativeEmoji[emoji] = (negativeEmoji[emoji] || 0) + count;
431
+ }
432
+ }
433
+ }
434
+
435
+ // Collect text feedback (most recent first, cap at 5)
436
+ const quotes = [];
437
+ for (const f of relevant) {
438
+ for (const c of (f.comments || [])) {
439
+ if (c.text && c.text.trim()) {
440
+ quotes.push(c.text.trim().substring(0, 120));
441
+ }
442
+ }
443
+ }
444
+ const topQuotes = quotes.slice(0, 5);
445
+
446
+ // If no signal at all, skip
447
+ if (positiveTotal === 0 && negativeTotal === 0 && topQuotes.length === 0) return '';
448
+
449
+ // Build compact summary
450
+ const parts = [];
451
+ parts.push('## Digest Preferences (from team feedback)');
452
+ parts.push('The team has reacted to recent digests. Use this to calibrate what you surface:');
453
+
454
+ if (positiveTotal > 0) {
455
+ const topPositive = Object.entries(positiveEmoji)
456
+ .sort((a, b) => b[1] - a[1])
457
+ .slice(0, 3)
458
+ .map(([e]) => ':' + e + ':')
459
+ .join(' ');
460
+ parts.push(`Positive signals (${positiveTotal} reactions: ${topPositive}) — do more of what recent digests covered.`);
461
+ }
462
+
463
+ if (negativeTotal > 0) {
464
+ const topNegative = Object.entries(negativeEmoji)
465
+ .sort((a, b) => b[1] - a[1])
466
+ .slice(0, 3)
467
+ .map(([e]) => ':' + e + ':')
468
+ .join(' ');
469
+ parts.push(`Concerns (${negativeTotal} reactions: ${topNegative}) — reconsider emphasis or framing.`);
470
+ }
471
+
472
+ if (topQuotes.length > 0) {
473
+ parts.push('Direct feedback from the team:');
474
+ for (const q of topQuotes) {
475
+ parts.push(`- "${q}"`);
476
+ }
477
+ }
478
+
479
+ let result = parts.join('\n');
480
+
481
+ // Enforce char cap
482
+ if (result.length > maxChars) {
483
+ result = result.substring(0, maxChars - 3) + '...';
484
+ }
485
+
486
+ return result;
487
+ }
488
+
489
+ /**
490
+ * Build the system prompt and user message for a persona digest.
491
+ * @param {string} personaId - Persona identifier (e.g. 'engineering', 'product')
492
+ * @param {string} signalContent - Collected signal data
493
+ * @param {string} journalContent - Collected journal entries
494
+ * @param {{ from: string, to: string }} dateRange - Date range
495
+ * @param {Object} [context] - Optional enrichment context
496
+ * @param {string} [context.teamContextDir] - Path to team-context repo
497
+ * @returns {{ system: string, user: string }}
498
+ */
499
+ function buildPrompt(personaId, signalContent, journalContent, dateRange, context) {
500
+ const templatePath = path.join(__dirname, '..', 'templates', 'autopilot', `${personaId}.md`);
501
+
502
+ if (!fs.existsSync(templatePath)) {
503
+ throw new Error(`Persona template not found: ${templatePath}`);
504
+ }
505
+
506
+ const system = fs.readFileSync(templatePath, 'utf8');
507
+ const ctx = context || {};
508
+
509
+ // Assemble user message sections
510
+ const sections = [];
511
+ sections.push(`# Team Digest Input \u2014 ${dateRange.from} to ${dateRange.to}`);
512
+
513
+ // Team context (product strategy, architecture, etc.)
514
+ const teamContext = loadTeamContext(ctx.teamContextDir);
515
+ if (teamContext) {
516
+ sections.push(`## Team Context\n${teamContext}`);
517
+ }
518
+
519
+ // Team members with roles
520
+ const teamMembers = loadTeamMembers(ctx.teamContextDir);
521
+ if (teamMembers) {
522
+ sections.push(`## Team Members\n${teamMembers}`);
523
+ }
524
+
525
+ // Previous digest for dedup
526
+ const prevDigest = loadPreviousDigest(personaId, dateRange.to);
527
+ if (prevDigest) {
528
+ sections.push(`## Previous Digest\nThe following was the most recent digest. Do not repeat these items unless there is a meaningful update.\n\n${prevDigest}`);
529
+ }
530
+
531
+ // Team feedback on recent digests
532
+ const feedbackContext = buildFeedbackContext(personaId, { storePath: ctx.storePath });
533
+ if (feedbackContext) {
534
+ sections.push(feedbackContext);
535
+ }
536
+
537
+ // Excluded topics directive (driven by TEAM_CONTEXT_EXCLUDE_REPOS)
538
+ if (EXCLUDE_REPOS_RAW.length > 0) {
539
+ const names = EXCLUDE_REPOS_RAW.join(', ');
540
+ sections.push(`## CRITICAL: Excluded Topics\nYou MUST NOT mention the following projects anywhere in your output: ${names}. This includes their versions, features, releases, connectors, configuration, bugs, or any development work on them. Only write about items that appear explicitly in the signal data and journal entries below. Do not infer or fabricate items about projects not present in the input.`);
541
+ }
542
+
543
+ // Signal data
544
+ sections.push(`## Signal Data\n${signalContent || 'No signal data available for this period.'}`);
545
+
546
+ // Journal entries
547
+ sections.push(`## Session Journals\n${journalContent || 'No journal entries available for this period.'}`);
548
+
549
+ const user = sections.join('\n\n') + '\n';
550
+
551
+ return { system, user };
552
+ }
553
+
554
+ /**
555
+ * Apply token budget constraints to signal and journal content.
556
+ * Truncates oldest journal entries first, then signal content.
557
+ * @param {string} signalContent
558
+ * @param {string} journalContent
559
+ * @param {number} maxChars
560
+ * @returns {{ signals: string, journals: string, truncated: boolean }}
561
+ */
562
+ function applyTokenBudget(signalContent, journalContent, maxChars) {
563
+ const total = signalContent.length + journalContent.length;
564
+ if (total <= maxChars) {
565
+ return { signals: signalContent, journals: journalContent, truncated: false };
566
+ }
567
+
568
+ const truncationNote = '\n\n> Note: Input was truncated to fit within token budget. Some older entries may be omitted.\n';
569
+ const noteLen = truncationNote.length;
570
+ const available = maxChars - noteLen;
571
+
572
+ let trimmedJournals = journalContent;
573
+ let trimmedSignals = signalContent;
574
+ let journalsTrimmed = false;
575
+ let signalsTrimmed = false;
576
+
577
+ // Strategy: drop oldest journal entries first, then trim signals
578
+ if (trimmedSignals.length + trimmedJournals.length > available) {
579
+ // Try trimming journals first (keep newest entries)
580
+ const journalBudget = Math.max(0, available - trimmedSignals.length);
581
+ if (journalBudget < trimmedJournals.length) {
582
+ trimmedJournals = trimmedJournals.slice(trimmedJournals.length - journalBudget);
583
+ journalsTrimmed = true;
584
+ }
585
+ }
586
+
587
+ if (trimmedSignals.length + trimmedJournals.length > available) {
588
+ // Still over — trim signal content from the end
589
+ const signalBudget = Math.max(0, available - trimmedJournals.length);
590
+ trimmedSignals = trimmedSignals.slice(0, signalBudget);
591
+ signalsTrimmed = true;
592
+ }
593
+
594
+ // Append truncation note to whichever content was actually trimmed
595
+ if (signalsTrimmed) {
596
+ trimmedSignals += truncationNote;
597
+ } else if (journalsTrimmed) {
598
+ trimmedJournals += truncationNote;
599
+ }
600
+
601
+ return {
602
+ signals: trimmedSignals,
603
+ journals: trimmedJournals,
604
+ truncated: true,
605
+ };
606
+ }
607
+
608
+ /**
609
+ * Generate digests for one or more personas.
610
+ * @param {Object} config - Digest config from connectors.json (has .llm and .slack keys)
611
+ * @param {string[]} personaIds - Array of persona identifiers
612
+ * @param {string} sinceDate - YYYY-MM-DD lookback date
613
+ * @param {Function} [onProgress] - Optional callback: { phase, personaId, index, total, elapsed }
614
+ * @returns {Promise<{ files: string[], personas: string[], dateRange: { from: string, to: string } }>}
615
+ */
616
+ async function generateDigest(config, personaIds, sinceDate, onProgress) {
617
+ const toDate = today();
618
+ const dateRange = { from: sinceDate, to: toDate };
619
+
620
+ // Try store-based collection first; fall back to raw file scan
621
+ let signalContent = '';
622
+ let journalContent = '';
623
+ const storeOpts = {
624
+ storePath: config.store_path,
625
+ journalDir: config.journal_dir,
626
+ signalsDir: config.signals_dir,
627
+ };
628
+ const storeResult = collectFromStore(sinceDate, storeOpts);
629
+ if (storeResult.entryCount > 0) {
630
+ journalContent = storeResult.journals;
631
+ // Use store signals if available, otherwise fall back to direct file scan
632
+ signalContent = storeResult.signals || collectSignals(sinceDate, config.signals_dir);
633
+ } else {
634
+ // Fallback: direct file scan (store not indexed)
635
+ signalContent = collectSignals(sinceDate, config.signals_dir);
636
+ journalContent = collectJournals(sinceDate, config.journal_dir);
637
+ }
638
+
639
+ // Filter out content mentioning excluded repos (TEAM_CONTEXT_EXCLUDE_REPOS)
640
+ journalContent = filterExcludedContent(journalContent);
641
+ signalContent = filterExcludedContent(signalContent);
642
+
643
+ // Apply token budget
644
+ const maxInputChars = (config.llm && config.llm.max_input_chars) || 120000;
645
+ const budget = applyTokenBudget(signalContent, journalContent, maxInputChars);
646
+ signalContent = budget.signals;
647
+ journalContent = budget.journals;
648
+
649
+ // Generate per-persona digests
650
+ const digestDir = path.join(HOME, '.claude', 'team-context', 'digests');
651
+ const files = [];
652
+ const personaResults = [];
653
+
654
+ for (let i = 0; i < personaIds.length; i++) {
655
+ const personaId = personaIds[i];
656
+
657
+ if (onProgress) {
658
+ onProgress({ phase: 'start', personaId, index: i, total: personaIds.length });
659
+ }
660
+
661
+ const startTime = Date.now();
662
+ const promptContext = { teamContextDir: config.team_context_dir };
663
+ const { system, user } = buildPrompt(personaId, signalContent, journalContent, dateRange, promptContext);
664
+
665
+ // Debug: dump prompt if TEAM_CONTEXT_DEBUG_PROMPT is set
666
+ if (process.env.TEAM_CONTEXT_DEBUG_PROMPT) {
667
+ const debugPath = path.join(digestDir, `_debug-prompt-${personaId}.txt`);
668
+ fs.mkdirSync(digestDir, { recursive: true });
669
+ fs.writeFileSync(debugPath, `=== SYSTEM ===\n${system}\n\n=== USER ===\n${user}`, 'utf8');
670
+ console.log(` [debug] Prompt dumped to ${debugPath}`);
671
+ }
672
+
673
+ const llmConfig = { ...config.llm, _personaId: personaId };
674
+ const result = await llm.call(llmConfig, system, user);
675
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
676
+
677
+ // Write per-persona file
678
+ const personaDir = path.join(digestDir, personaId);
679
+ fs.mkdirSync(personaDir, { recursive: true });
680
+ const personaFile = path.join(personaDir, `${toDate}.md`);
681
+ fs.writeFileSync(personaFile, result, 'utf8');
682
+ files.push(personaFile);
683
+
684
+ personaResults.push({ id: personaId, content: result });
685
+
686
+ if (onProgress) {
687
+ onProgress({ phase: 'done', personaId, index: i, total: personaIds.length, elapsed });
688
+ }
689
+ }
690
+
691
+ telemetry.capture('digest_generated', {
692
+ persona_count: personaIds.length,
693
+ personas: personaIds.join(','),
694
+ entry_count: storeResult.entryCount || 0,
695
+ journal_count: journalContent ? journalContent.split('\n---\n').length : 0,
696
+ signal_count: signalContent ? signalContent.split('\n---\n').length : 0,
697
+ has_previous_digest: !!loadPreviousDigest(personaIds[0], toDate),
698
+ has_team_context: !!loadTeamContext(config.team_context_dir),
699
+ });
700
+
701
+ // Write combined file
702
+ fs.mkdirSync(digestDir, { recursive: true });
703
+ const combinedContent = personaResults
704
+ .map((p) => {
705
+ const title = p.id === 'unified' ? 'Wayfind Digest' : `${p.id.charAt(0).toUpperCase() + p.id.slice(1)} Digest`;
706
+ return `# ${title}\n\n${p.content}`;
707
+ })
708
+ .join('\n\n---\n\n');
709
+ const combinedFile = path.join(digestDir, `${toDate}-combined.md`);
710
+ fs.writeFileSync(combinedFile, combinedContent, 'utf8');
711
+ files.push(combinedFile);
712
+
713
+ return { files, personas: personaIds, dateRange };
714
+ }
715
+
716
+ /**
717
+ * Interactive setup for digest configuration.
718
+ * Returns a config object (caller writes to disk).
719
+ * @returns {Promise<Object>}
720
+ */
721
+ async function configure() {
722
+ console.log('');
723
+ console.log('Digest Configuration');
724
+ console.log('');
725
+
726
+ // Step 1: LLM provider
727
+ const llmConfig = {};
728
+
729
+ // Auto-detect available provider
730
+ const detected = await llm.detect();
731
+ if (detected) {
732
+ console.log(`Detected: ${detected.provider} (${detected.model || 'default model'})`);
733
+ const useDetected = await ask(`Use ${detected.provider}? (Y/n): `);
734
+ if (!useDetected || useDetected.toLowerCase() !== 'n') {
735
+ Object.assign(llmConfig, detected);
736
+ }
737
+ }
738
+
739
+ if (!llmConfig.provider) {
740
+ console.log('Available providers: anthropic, openai, cli');
741
+ const provider = await ask('LLM provider: ');
742
+ llmConfig.provider = provider;
743
+
744
+ if (provider === 'anthropic') {
745
+ llmConfig.model = (await ask('Model (default: claude-sonnet-4-5-20250929): ')) || 'claude-sonnet-4-5-20250929';
746
+ llmConfig.api_key_env = 'ANTHROPIC_API_KEY';
747
+ } else if (provider === 'openai') {
748
+ llmConfig.model = (await ask('Model (default: gpt-4o-mini): ')) || 'gpt-4o-mini';
749
+ llmConfig.api_key_env = (await ask('API key env var (default: OPENAI_API_KEY): ')) || 'OPENAI_API_KEY';
750
+ const baseUrl = await ask('Base URL (blank for OpenAI, or http://localhost:11434/v1 for Ollama): ');
751
+ llmConfig.base_url = baseUrl || null;
752
+ } else if (provider === 'cli') {
753
+ llmConfig.command = await ask('Command (e.g. "ollama run llama3.2"): ');
754
+ }
755
+ }
756
+
757
+ // Step 1b: API key — save to ~/.claude/team-context/.env
758
+ if (llmConfig.api_key_env) {
759
+ const existing = process.env[llmConfig.api_key_env];
760
+ if (existing) {
761
+ console.log(`\n${llmConfig.api_key_env} found in environment.`);
762
+ const save = await ask('Save it to wayfind config so it works from any terminal? (Y/n): ');
763
+ if (!save || save.toLowerCase() !== 'n') {
764
+ saveEnvKey(llmConfig.api_key_env, existing);
765
+ }
766
+ } else {
767
+ console.log(`\n${llmConfig.api_key_env} not found in environment.`);
768
+ const key = await ask(`Paste your ${llmConfig.provider === 'anthropic' ? 'Anthropic' : 'OpenAI'} API key (sk-...): `);
769
+ if (key) {
770
+ saveEnvKey(llmConfig.api_key_env, key);
771
+ process.env[llmConfig.api_key_env] = key;
772
+ } else {
773
+ console.log(`Skipped. You\'ll need to set ${llmConfig.api_key_env} in your environment.`);
774
+ }
775
+ }
776
+ }
777
+
778
+ // Step 2: Slack webhook (optional)
779
+ console.log('');
780
+ const webhook = await ask('Slack incoming webhook URL (blank to skip): ');
781
+
782
+ // Step 3: Personas
783
+ console.log('');
784
+ const defaultPersonas = ['unified'];
785
+ console.log(`Default digest: unified (or choose: engineering, product, strategy, design)`);
786
+ const personaInput = await ask('Digest templates (comma-separated, blank for default): ');
787
+ const personas = personaInput
788
+ ? personaInput.split(',').map((s) => s.trim()).filter(Boolean)
789
+ : defaultPersonas;
790
+
791
+ return {
792
+ llm: llmConfig,
793
+ slack: {
794
+ webhook_url: webhook || null,
795
+ default_personas: personas,
796
+ },
797
+ lookback_days: 7,
798
+ configured_at: new Date().toISOString(),
799
+ };
800
+ }
801
+
802
+ module.exports = {
803
+ collectSignals,
804
+ collectJournals,
805
+ collectFromStore,
806
+ loadTeamContext,
807
+ loadTeamMembers,
808
+ loadPreviousDigest,
809
+ buildFeedbackContext,
810
+ buildPrompt,
811
+ generateDigest,
812
+ configure,
813
+ };