wayfind 2.0.22 → 2.0.23

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.
@@ -44,6 +44,15 @@ function loadDigestFixture(personaId) {
44
44
  }
45
45
  }
46
46
 
47
+ function loadScoringFixture() {
48
+ const fixturePath = path.join(getRepoRoot(), 'simulation', 'fixtures', 'intelligence', 'scores.json');
49
+ try {
50
+ return fs.readFileSync(fixturePath, 'utf8');
51
+ } catch {
52
+ return '[]';
53
+ }
54
+ }
55
+
47
56
  // ── HTTP helpers ─────────────────────────────────────────────────────────────
48
57
 
49
58
  /**
@@ -274,6 +283,9 @@ async function callCLI(config, systemPrompt, userContent) {
274
283
  async function call(config, systemPrompt, userContent) {
275
284
  // Simulation mode overrides any provider setting
276
285
  if (isSimulation() || config.provider === 'simulate') {
286
+ if (config._callType === 'scoring') {
287
+ return loadScoringFixture();
288
+ }
277
289
  const personaId = config._personaId || 'engineering';
278
290
  return loadDigestFixture(personaId);
279
291
  }
package/bin/digest.js CHANGED
@@ -7,9 +7,13 @@ const llm = require('./connectors/llm');
7
7
  const contentStore = require('./content-store');
8
8
  const telemetry = require('./telemetry');
9
9
 
10
+ const intelligence = require('./intelligence');
11
+
10
12
  const HOME = process.env.HOME || process.env.USERPROFILE;
11
13
  const WAYFIND_DIR = path.join(HOME, '.claude', 'team-context');
12
14
  const ENV_FILE = path.join(WAYFIND_DIR, '.env');
15
+ const ROOT = path.join(__dirname, '..');
16
+ const DEFAULT_PERSONAS_PATH = path.join(ROOT, 'templates', 'personas.json');
13
17
 
14
18
  // Team repo allowlist — mirrors content-store.js logic for digest-time filtering.
15
19
  // When INCLUDE_REPOS is set, sections mentioning repos NOT on the list are removed.
@@ -73,6 +77,31 @@ function filterExcludedContent(content) {
73
77
  return content;
74
78
  }
75
79
 
80
+ // ── Persona loading ─────────────────────────────────────────────────────────
81
+
82
+ /**
83
+ * Load active personas from user config or bundled default.
84
+ * Same resolution as team-context.js:readPersonas.
85
+ * @returns {Array<{id: string, name: string, description: string}>}
86
+ */
87
+ function loadPersonas() {
88
+ const candidates = [
89
+ path.join(HOME, '.claude', 'team-context', 'personas.json'),
90
+ path.join(HOME, '.ai-memory', 'team-context', 'personas.json'),
91
+ ];
92
+ let configPath = null;
93
+ for (const p of candidates) {
94
+ if (fs.existsSync(p)) { configPath = p; break; }
95
+ }
96
+ if (!configPath) configPath = DEFAULT_PERSONAS_PATH;
97
+ try {
98
+ const data = JSON.parse(fs.readFileSync(configPath, 'utf8'));
99
+ return data.personas || [];
100
+ } catch {
101
+ return [];
102
+ }
103
+ }
104
+
76
105
  // ── Env file helpers ────────────────────────────────────────────────────────
77
106
 
78
107
  /**
@@ -677,11 +706,23 @@ async function generateDigest(config, personaIds, sinceDate, onProgress) {
677
706
  journalContent = filterExcludedContent(journalContent);
678
707
  signalContent = filterExcludedContent(signalContent);
679
708
 
680
- // Apply token budget
709
+ // Preserve original content refs for telemetry
710
+ const rawSignalContent = signalContent;
711
+ const rawJournalContent = journalContent;
712
+
713
+ // Intelligence layer: score items for persona relevance
714
+ const personas = loadPersonas();
715
+ let scores = null;
716
+ if (config.intelligence?.enabled !== false && personas.length > 0) {
717
+ const haikuConfig = {
718
+ provider: config.llm.provider,
719
+ model: config.intelligence?.model || 'claude-haiku-4-5-20251001',
720
+ api_key_env: config.llm.api_key_env,
721
+ };
722
+ scores = await intelligence.scoreItems(signalContent, journalContent, personas, haikuConfig);
723
+ }
724
+
681
725
  const maxInputChars = (config.llm && config.llm.max_input_chars) || 120000;
682
- const budget = applyTokenBudget(signalContent, journalContent, maxInputChars);
683
- signalContent = budget.signals;
684
- journalContent = budget.journals;
685
726
 
686
727
  // Generate per-persona digests
687
728
  const digestDir = path.join(HOME, '.claude', 'team-context', 'digests');
@@ -696,8 +737,23 @@ async function generateDigest(config, personaIds, sinceDate, onProgress) {
696
737
  }
697
738
 
698
739
  const startTime = Date.now();
740
+
741
+ // Per-persona filtering: intelligence layer + token budget
742
+ let pSignals = signalContent;
743
+ let pJournals = journalContent;
744
+ if (scores) {
745
+ const threshold = config.intelligence?.thresholds?.[personaId]
746
+ ?? intelligence.DEFAULT_THRESHOLDS[personaId] ?? 1;
747
+ const allPersonaIds = personas.map(p => p.id);
748
+ ({ signals: pSignals, journals: pJournals } =
749
+ intelligence.filterForPersona(signalContent, journalContent, scores, personaId, threshold, allPersonaIds));
750
+ }
751
+ const budget = applyTokenBudget(pSignals, pJournals, maxInputChars);
752
+ pSignals = budget.signals;
753
+ pJournals = budget.journals;
754
+
699
755
  const promptContext = { teamContextDir: config.team_context_dir };
700
- const { system, user } = buildPrompt(personaId, signalContent, journalContent, dateRange, promptContext);
756
+ const { system, user } = buildPrompt(personaId, pSignals, pJournals, dateRange, promptContext);
701
757
 
702
758
  // Debug: dump prompt if TEAM_CONTEXT_DEBUG_PROMPT is set
703
759
  if (process.env.TEAM_CONTEXT_DEBUG_PROMPT) {
@@ -729,10 +785,12 @@ async function generateDigest(config, personaIds, sinceDate, onProgress) {
729
785
  persona_count: personaIds.length,
730
786
  personas: personaIds.join(','),
731
787
  entry_count: storeResult.entryCount || 0,
732
- journal_count: journalContent ? journalContent.split('\n---\n').length : 0,
733
- signal_count: signalContent ? signalContent.split('\n---\n').length : 0,
788
+ journal_count: rawJournalContent ? rawJournalContent.split('\n---\n').length : 0,
789
+ signal_count: rawSignalContent ? rawSignalContent.split('\n---\n').length : 0,
734
790
  has_previous_digest: !!loadPreviousDigest(personaIds[0], toDate),
735
791
  has_team_context: !!loadTeamContext(config.team_context_dir),
792
+ intelligence_enabled: config.intelligence?.enabled !== false,
793
+ intelligence_items_scored: scores ? scores.length : 0,
736
794
  });
737
795
 
738
796
  // Write combined file
@@ -843,6 +901,7 @@ module.exports = {
843
901
  loadTeamContext,
844
902
  loadTeamMembers,
845
903
  loadPreviousDigest,
904
+ loadPersonas,
846
905
  buildFeedbackContext,
847
906
  buildPrompt,
848
907
  generateDigest,
@@ -0,0 +1,155 @@
1
+ 'use strict';
2
+
3
+ const llm = require('./connectors/llm');
4
+
5
+ // Default thresholds per persona. 0=show everything, 1=tangential+relevant, 2=directly relevant only.
6
+ const DEFAULT_THRESHOLDS = {
7
+ engineering: 1,
8
+ product: 2,
9
+ design: 2,
10
+ strategy: 1,
11
+ unified: 0,
12
+ };
13
+
14
+ /**
15
+ * Build the scoring system prompt dynamically from active personas.
16
+ * @param {Array<{id: string, description: string}>} personas
17
+ * @returns {string}
18
+ */
19
+ function buildScoringPrompt(personas) {
20
+ const personaDefs = personas
21
+ .map(p => `- ${p.id}: ${p.description}`)
22
+ .join('\n');
23
+
24
+ const exampleKeys = personas.map(p => `"${p.id}":0`).join(',');
25
+
26
+ return `You score journal entries and signals for relevance to this team's personas.
27
+ Score each item 0-2 for each persona:
28
+ - 0 = not relevant to this persona
29
+ - 1 = tangentially relevant (context, not action)
30
+ - 2 = directly relevant (this persona should see this)
31
+
32
+ Personas:
33
+ ${personaDefs}
34
+
35
+ Return ONLY a JSON array. No explanation. Example:
36
+ [{"id":0,${exampleKeys}},{"id":1,${exampleKeys}}]`;
37
+ }
38
+
39
+ /**
40
+ * Split content on section separators and number items.
41
+ * @param {string} content
42
+ * @param {number} startId - Starting item ID
43
+ * @returns {{ items: string[], labeled: string }}
44
+ */
45
+ function splitAndLabel(content, startId) {
46
+ if (!content || !content.trim()) return { items: [], labeled: '' };
47
+ const items = content.split('\n\n---\n\n').filter(s => s.trim());
48
+ const labeled = items
49
+ .map((item, i) => `[ITEM ${startId + i}]\n${item}`)
50
+ .join('\n\n');
51
+ return { items, labeled };
52
+ }
53
+
54
+ /**
55
+ * Score signal and journal items for persona relevance using a single LLM call.
56
+ * @param {string} signalContent - Raw signal content (sections separated by \n\n---\n\n)
57
+ * @param {string} journalContent - Raw journal content (sections separated by \n\n---\n\n)
58
+ * @param {Array<{id: string, description: string}>} personas - Active personas
59
+ * @param {Object} llmConfig - LLM config for the scoring call
60
+ * @returns {Promise<Array<{id: number, [personaId]: number}>|null>} Scores array or null on failure
61
+ */
62
+ async function scoreItems(signalContent, journalContent, personas, llmConfig) {
63
+ const signalResult = splitAndLabel(signalContent, 0);
64
+ const journalResult = splitAndLabel(journalContent, signalResult.items.length);
65
+
66
+ const totalItems = signalResult.items.length + journalResult.items.length;
67
+ if (totalItems === 0) return null;
68
+
69
+ const systemPrompt = buildScoringPrompt(personas);
70
+
71
+ const userParts = [];
72
+ if (signalResult.labeled) {
73
+ userParts.push('## Signals\n\n' + signalResult.labeled);
74
+ }
75
+ if (journalResult.labeled) {
76
+ userParts.push('## Journals\n\n' + journalResult.labeled);
77
+ }
78
+ const userContent = userParts.join('\n\n---\n\n');
79
+
80
+ const config = {
81
+ ...llmConfig,
82
+ _personaId: 'scoring',
83
+ _callType: 'scoring',
84
+ max_tokens: Math.max(256, totalItems * 80),
85
+ };
86
+
87
+ try {
88
+ const response = await llm.call(config, systemPrompt, userContent);
89
+ let jsonStr = response.trim();
90
+ // Strip markdown fences if present (reuse pattern from content-store.js)
91
+ if (jsonStr.startsWith('```')) {
92
+ jsonStr = jsonStr.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
93
+ }
94
+ const scores = JSON.parse(jsonStr);
95
+ if (!Array.isArray(scores)) return null;
96
+ return scores;
97
+ } catch {
98
+ // Graceful fallback: caller passes everything through
99
+ return null;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Filter content sections based on persona relevance scores.
105
+ * @param {string} signalContent - Raw signal content
106
+ * @param {string} journalContent - Raw journal content
107
+ * @param {Array<{id: number}>} scores - Scoring results from scoreItems
108
+ * @param {string} personaId - Persona to filter for
109
+ * @param {number} threshold - Minimum score to include (0, 1, or 2)
110
+ * @param {string[]} allPersonaIds - All active persona IDs (for unified union logic)
111
+ * @returns {{ signals: string, journals: string }}
112
+ */
113
+ function filterForPersona(signalContent, journalContent, scores, personaId, threshold, allPersonaIds) {
114
+ const signalItems = (signalContent && signalContent.trim())
115
+ ? signalContent.split('\n\n---\n\n').filter(s => s.trim())
116
+ : [];
117
+ const journalItems = (journalContent && journalContent.trim())
118
+ ? journalContent.split('\n\n---\n\n').filter(s => s.trim())
119
+ : [];
120
+
121
+ function itemPasses(itemIndex) {
122
+ const score = scores.find(s => s.id === itemIndex);
123
+ if (!score) return true; // If no score for this item, include it (safe default)
124
+
125
+ if (personaId === 'unified') {
126
+ // Union: passes if ANY persona scores >= their threshold
127
+ return allPersonaIds.some(pid => {
128
+ const pidThreshold = DEFAULT_THRESHOLDS[pid] ?? 1;
129
+ return (score[pid] ?? 0) >= pidThreshold;
130
+ });
131
+ }
132
+
133
+ return (score[personaId] ?? 0) >= threshold;
134
+ }
135
+
136
+ const filteredSignals = signalItems
137
+ .filter((_, i) => itemPasses(i))
138
+ .join('\n\n---\n\n');
139
+
140
+ const signalCount = signalItems.length;
141
+ const filteredJournals = journalItems
142
+ .filter((_, i) => itemPasses(signalCount + i))
143
+ .join('\n\n---\n\n');
144
+
145
+ return { signals: filteredSignals, journals: filteredJournals };
146
+ }
147
+
148
+ module.exports = {
149
+ scoreItems,
150
+ filterForPersona,
151
+ DEFAULT_THRESHOLDS,
152
+ // Exported for testing
153
+ buildScoringPrompt,
154
+ splitAndLabel,
155
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wayfind",
3
- "version": "2.0.22",
3
+ "version": "2.0.23",
4
4
  "description": "Team decision trail for AI-assisted development. The connective tissue between product, engineering, and strategy.",
5
5
  "bin": {
6
6
  "wayfind": "./bin/team-context.js"
@@ -8,6 +8,8 @@ Find the 5 most consequential design items from this period. "Consequential" mea
8
8
 
9
9
  ## Rules
10
10
 
11
+ - **Translate to user impact.** If a code change made the cut, describe what the user sees or
12
+ interacts with differently. Translate technical changes into UX consequences.
11
13
  - **Maximum 5 items.** If fewer than 5 things matter, output fewer. Never pad.
12
14
  - **Rank by consequence, not category.** Lead with the item that most affects user experience.
13
15
  - **No category headers.** No "UX Consistency" / "Accessibility" sections. Just a ranked list.
@@ -8,6 +8,9 @@ Find the 5 most consequential product items from this period. "Consequential" me
8
8
 
9
9
  ## Rules
10
10
 
11
+ - **Translate, don't transcribe.** If an engineering item made the cut, describe its product
12
+ implication — not its technical cause. "Hotel data gaps visible to customers in analytics"
13
+ not "nestedSource config missing in Function App." The reader is a PM, not a developer.
11
14
  - **Maximum 5 items.** If fewer than 5 things matter, output fewer. Never pad.
12
15
  - **Rank by consequence, not category.** Lead with the item that most affects customers or roadmap.
13
16
  - **No category headers.** No "Scope Drift" / "Customer Issues" sections. Just a ranked list.
@@ -8,6 +8,9 @@ Find the 5 most consequential strategic items from this period. "Consequential"
8
8
 
9
9
  ## Rules
10
10
 
11
+ - **Extract the strategic pattern.** If day-to-day engineering work made the cut, connect it to
12
+ business goals, competitive positioning, or resource allocation. The reader thinks in quarters,
13
+ not sprints.
11
14
  - **Maximum 5 items.** If fewer than 5 things matter, output fewer. Never pad.
12
15
  - **Rank by consequence, not category.** Lead with the item that most affects the business trajectory.
13
16
  - **No category headers.** No "Competitive" / "Strategic Drift" sections. Just a ranked list.