wayfind 2.0.22 → 2.0.24

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
+ };
@@ -4729,6 +4729,21 @@ const COMMANDS = {
4729
4729
  'sync-public': {
4730
4730
  desc: 'Sync code to the public usewayfind/wayfind repo',
4731
4731
  run: () => {
4732
+ // Source must be the repo checkout you're working in, not the npm global install.
4733
+ // Use cwd if it looks like a wayfind repo, otherwise fall back to ROOT.
4734
+ const cwdPkg = path.join(process.cwd(), 'package.json');
4735
+ let sourceRoot = ROOT;
4736
+ if (fs.existsSync(cwdPkg)) {
4737
+ try {
4738
+ const pkg = JSON.parse(fs.readFileSync(cwdPkg, 'utf8'));
4739
+ if (pkg.name === 'wayfind') sourceRoot = process.cwd();
4740
+ } catch {}
4741
+ }
4742
+ if (sourceRoot === ROOT && ROOT !== process.cwd()) {
4743
+ console.log(`Syncing from: ${sourceRoot}`);
4744
+ console.log('Tip: run from your wayfind repo checkout to sync local changes.');
4745
+ }
4746
+
4732
4747
  const tmpDir = path.join(os.tmpdir(), 'wayfind-public-sync');
4733
4748
  const publicRepo = process.env.WAYFIND_PUBLIC_REPO || 'https://github.com/usewayfind/wayfind.git';
4734
4749
 
@@ -4761,13 +4776,13 @@ const COMMANDS = {
4761
4776
  const privateOnlyWorkflows = ['sync-public.yml', 'simulation.yml'];
4762
4777
 
4763
4778
  // Also sync public-staging docs if they exist
4764
- const publicDocsDir = path.join(ROOT, 'public-staging', 'docs');
4779
+ const publicDocsDir = path.join(sourceRoot, 'public-staging', 'docs');
4765
4780
 
4766
4781
  console.log('Syncing files...');
4767
4782
  for (const item of syncItems) {
4768
4783
  const isDir = item.endsWith('/');
4769
4784
  const name = item.replace(/\/$/, '');
4770
- const src = path.join(ROOT, name);
4785
+ const src = path.join(sourceRoot, name);
4771
4786
  if (!fs.existsSync(src)) continue;
4772
4787
  if (isDir) {
4773
4788
  // rsync without trailing slash on source copies the directory itself into dest
@@ -4787,7 +4802,7 @@ const COMMANDS = {
4787
4802
 
4788
4803
  // Sync public-staging/ → public repo root (recursive)
4789
4804
  // This overlays LICENSE, README, CHANGELOG, SECURITY, CONTRIBUTING, docs/, etc.
4790
- const publicStagingDir = path.join(ROOT, 'public-staging');
4805
+ const publicStagingDir = path.join(sourceRoot, 'public-staging');
4791
4806
  if (fs.existsSync(publicStagingDir)) {
4792
4807
  spawnSync('rsync', ['-a', publicStagingDir + '/', tmpDir + '/'], { stdio: 'inherit' });
4793
4808
  }
@@ -4795,7 +4810,7 @@ const COMMANDS = {
4795
4810
  // ── Sanitization gate ───────────────────────────────────────────────
4796
4811
  // Scan all tracked text files for proprietary patterns before pushing.
4797
4812
  // Patterns loaded from .sync-blocklist (not synced to public repo).
4798
- const blocklistPath = path.join(ROOT, '.sync-blocklist');
4813
+ const blocklistPath = path.join(sourceRoot, '.sync-blocklist');
4799
4814
  const BLOCKED_PATTERNS = [];
4800
4815
  if (fs.existsSync(blocklistPath)) {
4801
4816
  for (const line of fs.readFileSync(blocklistPath, 'utf8').split('\n')) {
@@ -4873,7 +4888,7 @@ const COMMANDS = {
4873
4888
 
4874
4889
  // Get version for commit message
4875
4890
  let version = 'unknown';
4876
- try { version = require(path.join(ROOT, 'package.json')).version; } catch {}
4891
+ try { version = require(path.join(sourceRoot, 'package.json')).version; } catch {}
4877
4892
 
4878
4893
  // Commit and push
4879
4894
  spawnSync('git', ['add', '-A'], { cwd: tmpDir, stdio: 'inherit' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wayfind",
3
- "version": "2.0.22",
3
+ "version": "2.0.24",
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.