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.
- package/BOOTSTRAP_PROMPT.md +120 -0
- package/bin/connectors/github.js +617 -0
- package/bin/connectors/index.js +13 -0
- package/bin/connectors/intercom.js +595 -0
- package/bin/connectors/llm.js +469 -0
- package/bin/connectors/notion.js +747 -0
- package/bin/connectors/transport.js +325 -0
- package/bin/content-store.js +2006 -0
- package/bin/digest.js +813 -0
- package/bin/rebuild-status.js +297 -0
- package/bin/slack-bot.js +1535 -0
- package/bin/slack.js +342 -0
- package/bin/storage/index.js +171 -0
- package/bin/storage/json-backend.js +348 -0
- package/bin/storage/sqlite-backend.js +415 -0
- package/bin/team-context.js +4209 -0
- package/bin/telemetry.js +159 -0
- package/doctor.sh +291 -0
- package/install.sh +144 -0
- package/journal-summary.sh +577 -0
- package/package.json +48 -6
- package/setup.sh +641 -0
- package/specializations/claude-code/CLAUDE.md-global-fragment.md +53 -0
- package/specializations/claude-code/CLAUDE.md-repo-fragment.md +16 -0
- package/specializations/claude-code/README.md +99 -0
- package/specializations/claude-code/commands/doctor.md +31 -0
- package/specializations/claude-code/commands/init-memory.md +154 -0
- package/specializations/claude-code/commands/init-team.md +415 -0
- package/specializations/claude-code/commands/journal.md +66 -0
- package/specializations/claude-code/commands/review-prs.md +119 -0
- package/specializations/claude-code/hooks/check-global-state.sh +20 -0
- package/specializations/claude-code/hooks/session-end.sh +36 -0
- package/specializations/claude-code/settings.json +15 -0
- package/specializations/cursor/README.md +120 -0
- package/specializations/cursor/global-rule.mdc +53 -0
- package/specializations/cursor/repo-rule.mdc +25 -0
- package/specializations/generic/README.md +47 -0
- package/templates/autopilot/design.md +22 -0
- package/templates/autopilot/engineering.md +22 -0
- package/templates/autopilot/product.md +22 -0
- package/templates/autopilot/strategy.md +22 -0
- package/templates/autopilot/unified.md +24 -0
- package/templates/deploy/.env.example +110 -0
- package/templates/deploy/docker-compose.yml +63 -0
- package/templates/deploy/slack-app-manifest.json +45 -0
- package/templates/github-actions/meridian-digest.yml +85 -0
- package/templates/global.md +79 -0
- package/templates/memory-file.md +18 -0
- package/templates/personal-state.md +14 -0
- package/templates/personas.json +28 -0
- package/templates/product-state.md +41 -0
- package/templates/prompts-readme.md +19 -0
- package/templates/repo-state.md +18 -0
- package/templates/session-protocol-fragment.md +46 -0
- package/templates/slack-app-manifest.json +27 -0
- package/templates/statusline.sh +22 -0
- package/templates/strategy-state.md +39 -0
- package/templates/team-state.md +55 -0
- package/uninstall.sh +105 -0
- 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
|
+
});
|