@overscore/cli 0.9.14 → 0.10.1

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/dist/index.js CHANGED
@@ -47,7 +47,29 @@ function loadEnv() {
47
47
  };
48
48
  }
49
49
  }
50
- console.error(" Error: No credentials found. Run 'npx @overscore/cli auth login' to set up.");
50
+ // No creds. Tailor the message based on what kind of folder we're in so the
51
+ // user gets pointed at the right setup command.
52
+ const inAnalysisFolder = fs.existsSync(path.resolve(process.cwd(), ".overscore", "analysis.json"));
53
+ if (inAnalysisFolder) {
54
+ let analysisSlug = null;
55
+ try {
56
+ const meta = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), ".overscore", "analysis.json"), "utf-8"));
57
+ analysisSlug = typeof meta.slug === "string" ? meta.slug : null;
58
+ }
59
+ catch {
60
+ // Fall through to the generic message.
61
+ }
62
+ console.error("\n Error: No credentials found in this analysis folder.");
63
+ console.error(" This usually means the analysis hasn't been linked to your account yet.");
64
+ console.error(" Run:");
65
+ console.error(` npx @overscore/cli analysis pull ${analysisSlug || "<slug>"}`);
66
+ console.error(" to refresh credentials and link this folder, or:");
67
+ console.error(" npx @overscore/cli auth login");
68
+ console.error(" if you've never set up the CLI on this machine.\n");
69
+ }
70
+ else {
71
+ console.error("\n Error: No credentials found. Run 'npx @overscore/cli auth login' to set up.\n");
72
+ }
51
73
  process.exit(1);
52
74
  }
53
75
  async function apiRequest(method, urlPath, body) {
@@ -239,7 +261,10 @@ async function deploy() {
239
261
  // Sync context files to Hub
240
262
  const { projectSlug } = loadEnv();
241
263
  const baseUrl = apiUrl.replace(/\/api$/, "");
242
- // Sync company context (with conflict detection)
264
+ // Upload knowledge base (project-level, shared across all artifacts)
265
+ await uploadKnowledgeBase(process.cwd(), baseUrl, projectSlug, apiKey);
266
+ // Sync company context (with conflict detection) — legacy path for
267
+ // projects that haven't migrated to the knowledge base yet.
243
268
  const companyCtxPath = path.resolve(process.cwd(), ".claude/rules/company-context.md");
244
269
  if (fs.existsSync(companyCtxPath)) {
245
270
  const content = fs.readFileSync(companyCtxPath, "utf-8");
@@ -282,9 +307,11 @@ async function deploy() {
282
307
  }
283
308
  }
284
309
  // Sync dashboard context (no conflict detection needed — per-dashboard)
285
- const dashCtxPath = path.resolve(process.cwd(), ".claude/rules/dashboard-context.md");
286
- if (fs.existsSync(dashCtxPath)) {
287
- const content = fs.readFileSync(dashCtxPath, "utf-8");
310
+ const dashCtxPath = path.resolve(process.cwd(), ".claude/rules/this-dashboard.md");
311
+ const legacyDashCtxPath = path.resolve(process.cwd(), ".claude/rules/dashboard-context.md");
312
+ const activeDashCtxPath = fs.existsSync(dashCtxPath) ? dashCtxPath : fs.existsSync(legacyDashCtxPath) ? legacyDashCtxPath : null;
313
+ if (activeDashCtxPath) {
314
+ const content = fs.readFileSync(activeDashCtxPath, "utf-8");
288
315
  try {
289
316
  await fetch(`${baseUrl}/api/dashboards/${dashboardSlug}/context`, {
290
317
  method: "PUT",
@@ -307,21 +334,43 @@ async function queryList() {
307
334
  const { dashboardSlug } = loadEnv();
308
335
  const data = (await apiRequest("GET", `/api/dashboards/${dashboardSlug}/queries`));
309
336
  console.log(`\n Dashboard: ${data.dashboard}\n`);
337
+ // Registered queries
310
338
  if (data.queries.length === 0) {
311
339
  console.log(" No queries registered yet.\n");
312
- console.log(" Add one with:");
313
- console.log(' npx @overscore/cli query add <name> "SELECT ..."\n');
314
- return;
340
+ console.log(" Add a SQL query: npx @overscore/cli query add <name> \"SELECT ...\"\n");
315
341
  }
316
- for (const q of data.queries) {
317
- const rowInfo = q.row_count !== null ? ` (~${q.row_count.toLocaleString()} rows)` : "";
318
- console.log(` ${q.query_name}${rowInfo}`);
319
- console.log(` useQuery("${q.query_name}")`);
320
- if (q.sample_row) {
321
- const cols = Object.keys(q.sample_row).join(", ");
322
- console.log(` columns: ${cols}`);
342
+ else {
343
+ console.log(" Registered Queries:\n");
344
+ for (const q of data.queries) {
345
+ const rowInfo = q.row_count !== null ? ` (~${q.row_count.toLocaleString()} rows)` : "";
346
+ const sourceTag = q.source === "dataset" ? " [dataset]" : "";
347
+ console.log(` ${q.query_name}${rowInfo}${sourceTag}`);
348
+ console.log(` useQuery("${q.query_name}")`);
349
+ if (q.sample_row) {
350
+ const cols = Object.keys(q.sample_row).join(", ");
351
+ console.log(` columns: ${cols}`);
352
+ }
353
+ else if (q.columns && q.columns.length > 0) {
354
+ const cols = q.columns.map((c) => `${c.name} (${c.type})`).join(", ");
355
+ console.log(` columns: ${cols}`);
356
+ }
357
+ console.log();
358
+ }
359
+ }
360
+ // Project datasets (always shown)
361
+ if (data.project_datasets && data.project_datasets.length > 0) {
362
+ console.log(" Project Datasets:\n");
363
+ for (const ds of data.project_datasets) {
364
+ const rowInfo = ds.row_count !== null ? ` (~${ds.row_count.toLocaleString()} rows)` : "";
365
+ const sampleTag = ds.is_sample ? " [sample]" : "";
366
+ console.log(` ${ds.name}${rowInfo}${sampleTag}`);
367
+ if (ds.columns && ds.columns.length > 0) {
368
+ const cols = ds.columns.map((c) => `${c.name} (${c.type})`).join(", ");
369
+ console.log(` columns: ${cols}`);
370
+ }
371
+ console.log(` To use: npx @overscore/cli query add <query-name> --dataset ${ds.name}`);
372
+ console.log();
323
373
  }
324
- console.log();
325
374
  }
326
375
  }
327
376
  async function queryShow(name) {
@@ -343,16 +392,28 @@ async function queryShow(name) {
343
392
  }
344
393
  console.log(`\n ${query.query_name}`);
345
394
  if (query.row_count !== null) {
346
- console.log(` ~${query.row_count.toLocaleString()} rows\n`);
395
+ console.log(` ~${query.row_count.toLocaleString()} rows`);
347
396
  }
348
- else {
349
- console.log();
397
+ if (query.source === "dataset" && query.dataset_name) {
398
+ console.log(` source: uploaded dataset (${query.dataset_name})`);
399
+ }
400
+ console.log();
401
+ if (query.source === "dataset") {
402
+ if (query.columns && query.columns.length > 0) {
403
+ console.log(` Columns:`);
404
+ for (const col of query.columns) {
405
+ console.log(` ${col.name} (${col.type})`);
406
+ }
407
+ console.log();
408
+ }
350
409
  }
351
- console.log(` SQL:`);
352
- console.log(` ${query.sql}\n`);
353
- if (query.sample_row) {
354
- console.log(` Sample row:`);
355
- console.log(` ${JSON.stringify(query.sample_row, null, 2).split("\n").join("\n ")}\n`);
410
+ else if (query.sql) {
411
+ console.log(` SQL:`);
412
+ console.log(` ${query.sql}\n`);
413
+ if (query.sample_row) {
414
+ console.log(` Sample row:`);
415
+ console.log(` ${JSON.stringify(query.sample_row, null, 2).split("\n").join("\n ")}\n`);
416
+ }
356
417
  }
357
418
  }
358
419
  function resolveSql(sqlOrNext) {
@@ -369,10 +430,15 @@ function resolveSql(sqlOrNext) {
369
430
  return sqlOrNext;
370
431
  }
371
432
  async function queryAdd(name, sql) {
433
+ // Check for --dataset flag
434
+ const datasetFlagIndex = process.argv.indexOf("--dataset");
435
+ const datasetName = datasetFlagIndex !== -1 ? process.argv[datasetFlagIndex + 1] : undefined;
372
436
  const resolvedSql = resolveSql(sql);
373
- if (!name || !resolvedSql) {
437
+ if (!name || (!resolvedSql && !datasetName)) {
374
438
  console.error('\n Usage: npx @overscore/cli query add <name> "<sql>"\n');
375
- console.error(" Or with a file:");
439
+ console.error(" Or link an uploaded dataset:");
440
+ console.error(" npx @overscore/cli query add <name> --dataset <dataset-name>\n");
441
+ console.error(" Or with a SQL file:");
376
442
  console.error(" npx @overscore/cli query add <name> --file query.sql\n");
377
443
  process.exit(1);
378
444
  }
@@ -381,7 +447,26 @@ async function queryAdd(name, sql) {
381
447
  process.exit(1);
382
448
  }
383
449
  const { dashboardSlug } = loadEnv();
384
- const data = (await apiRequest("POST", `/api/dashboards/${dashboardSlug}/queries`, { query_name: name, sql_text: resolvedSql }));
450
+ let body;
451
+ if (datasetName) {
452
+ // Look up the dataset UUID from project_datasets in the queries endpoint
453
+ const listData = (await apiRequest("GET", `/api/dashboards/${dashboardSlug}/queries`));
454
+ const dataset = (listData.project_datasets ?? []).find((ds) => ds.name === datasetName);
455
+ if (!dataset) {
456
+ console.error(`\n Error: Dataset "${datasetName}" not found in this project.\n`);
457
+ console.error(" Available datasets:");
458
+ for (const ds of listData.project_datasets ?? []) {
459
+ console.error(` - ${ds.name}`);
460
+ }
461
+ console.error();
462
+ process.exit(1);
463
+ }
464
+ body = { query_name: name, dataset_id: dataset.id };
465
+ }
466
+ else {
467
+ body = { query_name: name, sql_text: resolvedSql };
468
+ }
469
+ const data = (await apiRequest("POST", `/api/dashboards/${dashboardSlug}/queries`, body));
385
470
  console.log(`\n Query "${data.query_name}" created.\n`);
386
471
  console.log(` Use it in your dashboard:`);
387
472
  console.log(` const { data } = useQuery("${data.query_name}");\n`);
@@ -413,16 +498,34 @@ async function queryRemove(name) {
413
498
  await apiRequest("DELETE", `/api/dashboards/${dashboardSlug}/queries/${encodeURIComponent(name)}`);
414
499
  console.log(`\n Query "${name}" deleted.\n`);
415
500
  }
416
- function saveQueryResult(sql, data) {
501
+ function saveQueryResult(sql, data, sourceFile) {
417
502
  try {
418
503
  const hash = createHash("sha256").update(sql).digest("hex").slice(0, 8);
419
- const snippet = sql
420
- .toLowerCase()
421
- .replace(/[^a-z0-9_]+/g, "_")
422
- .replace(/^_|_$/g, "")
423
- .slice(0, 40);
424
- const filename = `${hash}_${snippet}.json`;
425
504
  const dataDir = path.resolve(process.cwd(), "data");
505
+ // Check for ANY existing file with this hash — handles both old and new
506
+ // filename formats so the same query isn't duplicated.
507
+ if (fs.existsSync(dataDir)) {
508
+ const existing = fs.readdirSync(dataDir).find((f) => f.includes(hash) && f.endsWith(".json"));
509
+ if (existing) {
510
+ return { path: `data/${existing}`, cached: true };
511
+ }
512
+ }
513
+ // Build filename: human-readable prefix when from a file, hash prefix otherwise
514
+ let filename;
515
+ if (sourceFile) {
516
+ const baseName = path.basename(sourceFile, path.extname(sourceFile));
517
+ filename = `${baseName}__${hash}.json`;
518
+ }
519
+ else {
520
+ const snippet = sql
521
+ .toLowerCase()
522
+ .replace(/[^a-z0-9_]+/g, "_")
523
+ .replace(/^_|_$/g, "")
524
+ .slice(0, 40);
525
+ filename = `${hash}_${snippet}.json`;
526
+ }
527
+ const fullPath = path.join(dataDir, filename);
528
+ const relPath = `data/${filename}`;
426
529
  if (!fs.existsSync(dataDir)) {
427
530
  fs.mkdirSync(dataDir, { recursive: true });
428
531
  }
@@ -434,27 +537,55 @@ function saveQueryResult(sql, data) {
434
537
  columns: data.data.length > 0 ? Object.keys(data.data[0]) : [],
435
538
  data: data.data,
436
539
  };
437
- fs.writeFileSync(path.join(dataDir, filename), JSON.stringify(payload, null, 2));
438
- return `data/${filename}`;
540
+ fs.writeFileSync(fullPath, JSON.stringify(payload, null, 2));
541
+ return { path: relPath, cached: false };
439
542
  }
440
543
  catch {
441
544
  return null;
442
545
  }
443
546
  }
444
547
  async function queryTest(sql) {
445
- if (!sql) {
446
- console.error('\n Usage: npx @overscore/cli query test "<sql>"\n');
447
- console.error(" Example:");
448
- console.error(' npx @overscore/cli query test "SELECT * FROM dataset.table LIMIT 10"\n');
548
+ const resolvedSql = resolveSql(sql);
549
+ if (!resolvedSql) {
550
+ console.error('\n Usage: npx @overscore/cli query run "<sql>"\n');
551
+ console.error(" Or with a file:");
552
+ console.error(" npx @overscore/cli query run --file queries/foo.sql\n");
449
553
  process.exit(1);
450
554
  }
451
555
  const { projectSlug } = loadEnv();
452
- const data = (await apiRequest("POST", `/api/projects/${projectSlug}/test-query`, { sql }));
453
- const savedPath = saveQueryResult(sql, data);
556
+ if (!projectSlug) {
557
+ const analysisMetaPath = path.resolve(process.cwd(), ".overscore", "analysis.json");
558
+ if (fs.existsSync(analysisMetaPath)) {
559
+ let slug = "<slug>";
560
+ try {
561
+ const meta = JSON.parse(fs.readFileSync(analysisMetaPath, "utf-8"));
562
+ if (typeof meta.slug === "string")
563
+ slug = meta.slug;
564
+ }
565
+ catch {
566
+ // ignore
567
+ }
568
+ console.error("\n Error: This analysis folder isn't linked to a project yet.");
569
+ console.error(` Run: npx @overscore/cli analysis pull ${slug}\n`);
570
+ }
571
+ else {
572
+ console.error("\n Error: No project slug configured. Run 'npx @overscore/cli auth login' to set one up.\n");
573
+ }
574
+ process.exit(1);
575
+ }
576
+ const data = (await apiRequest("POST", `/api/projects/${projectSlug}/test-query`, { sql: resolvedSql }));
577
+ const fileIndex = process.argv.indexOf("--file");
578
+ const sourceFile = fileIndex !== -1 ? process.argv[fileIndex + 1] : undefined;
579
+ const saved = saveQueryResult(resolvedSql, data, sourceFile);
580
+ const savedLine = saved
581
+ ? saved.cached
582
+ ? ` Cached (already saved earlier): ${saved.path}`
583
+ : ` Saved to ${saved.path}`
584
+ : null;
454
585
  console.log(`\n ${data.row_count} rows returned${data.truncated ? " (showing first 200)" : ""}\n`);
455
586
  if (data.data.length === 0) {
456
- if (savedPath)
457
- console.log(` Saved to ${savedPath}\n`);
587
+ if (savedLine)
588
+ console.log(`${savedLine}\n`);
458
589
  console.log(" No rows.\n");
459
590
  return;
460
591
  }
@@ -481,8 +612,8 @@ async function queryTest(sql) {
481
612
  if (data.data.length > 10) {
482
613
  console.log(`\n ... and ${data.data.length - 10} more rows`);
483
614
  }
484
- if (savedPath)
485
- console.log(`\n Saved to ${savedPath}`);
615
+ if (savedLine)
616
+ console.log(`\n${savedLine}`);
486
617
  console.log();
487
618
  }
488
619
  function formatCell(value) {
@@ -601,7 +732,7 @@ VITE_OVERSCORE_API_URL=https://overscore.dev/api
601
732
  if (dashCtxData.context_md) {
602
733
  const rulesDir = path.join(targetDir, ".claude", "rules");
603
734
  fs.mkdirSync(rulesDir, { recursive: true });
604
- fs.writeFileSync(path.join(rulesDir, "dashboard-context.md"), dashCtxData.context_md);
735
+ fs.writeFileSync(path.join(rulesDir, "this-dashboard.md"), dashCtxData.context_md);
605
736
  console.log(" Updated dashboard context from Hub.");
606
737
  }
607
738
  }
@@ -833,19 +964,23 @@ async function syncHubManagedRules(targetDir, baseUrl, projectSlug, apiKey) {
833
964
  const authHeader = { Authorization: `Bearer ${apiKey}` };
834
965
  const rulesDir = path.join(targetDir, ".claude", "rules");
835
966
  fs.mkdirSync(rulesDir, { recursive: true });
836
- // Company context null means "no company context set yet"; leave any
837
- // existing file alone in that case.
838
- try {
839
- const ctxRes = await fetch(`${baseUrl}/api/projects/${projectSlug}/context`, { headers: authHeader });
840
- if (ctxRes.ok) {
841
- const ctxData = (await ctxRes.json());
842
- if (ctxData.context_md) {
843
- fs.writeFileSync(path.join(rulesDir, "company-context.md"), ctxData.context_md);
967
+ // Try knowledge base first if the project has knowledge files, they
968
+ // replace the legacy company-context.md.
969
+ const hasKnowledge = await syncKnowledgeBase(targetDir, baseUrl, projectSlug, apiKey);
970
+ // Company context only fetch if no knowledge files exist (legacy path).
971
+ if (!hasKnowledge) {
972
+ try {
973
+ const ctxRes = await fetch(`${baseUrl}/api/projects/${projectSlug}/context`, { headers: authHeader });
974
+ if (ctxRes.ok) {
975
+ const ctxData = (await ctxRes.json());
976
+ if (ctxData.context_md) {
977
+ fs.writeFileSync(path.join(rulesDir, "company-context.md"), ctxData.context_md);
978
+ }
844
979
  }
845
980
  }
846
- }
847
- catch {
848
- // Non-fatal
981
+ catch {
982
+ // Non-fatal
983
+ }
849
984
  }
850
985
  // SQL style override — null means "use the CLI default"; leave the
851
986
  // template/bundle file alone in that case.
@@ -862,6 +997,93 @@ async function syncHubManagedRules(targetDir, baseUrl, projectSlug, apiKey) {
862
997
  // Non-fatal
863
998
  }
864
999
  }
1000
+ /**
1001
+ * Sync the project knowledge base from the Hub into .claude/knowledge/.
1002
+ * Returns true if knowledge files were found (caller can skip legacy
1003
+ * company-context.md in that case).
1004
+ */
1005
+ async function syncKnowledgeBase(targetDir, baseUrl, projectSlug, apiKey) {
1006
+ const authHeader = { Authorization: `Bearer ${apiKey}` };
1007
+ const knowledgeDir = path.join(targetDir, ".claude", "knowledge");
1008
+ try {
1009
+ const res = await fetch(`${baseUrl}/api/projects/${projectSlug}/knowledge`, { headers: authHeader });
1010
+ if (!res.ok)
1011
+ return false;
1012
+ const data = (await res.json());
1013
+ if (!data.files || data.files.length === 0)
1014
+ return false;
1015
+ fs.mkdirSync(knowledgeDir, { recursive: true });
1016
+ // Write each knowledge file
1017
+ for (const file of data.files) {
1018
+ fs.writeFileSync(path.join(knowledgeDir, file.path), file.content);
1019
+ }
1020
+ // Generate INDEX.md
1021
+ const indexLines = [
1022
+ "# Project Knowledge Base",
1023
+ "",
1024
+ "Read the relevant file when working on a topic. Do NOT read all files every session.",
1025
+ "",
1026
+ ];
1027
+ for (const file of data.files) {
1028
+ indexLines.push(`- **${file.title}**: \`.claude/knowledge/${file.path}\``);
1029
+ }
1030
+ indexLines.push("");
1031
+ fs.writeFileSync(path.join(knowledgeDir, "INDEX.md"), indexLines.join("\n"));
1032
+ // Store hashes for conflict detection on upload
1033
+ const hashMap = {};
1034
+ for (const file of data.files) {
1035
+ hashMap[file.path] = file.content_hash;
1036
+ }
1037
+ fs.writeFileSync(path.join(knowledgeDir, ".knowledge-hashes"), JSON.stringify(hashMap, null, 2));
1038
+ console.log(` Knowledge base synced (${data.files.length} files).`);
1039
+ return true;
1040
+ }
1041
+ catch {
1042
+ return false;
1043
+ }
1044
+ }
1045
+ /**
1046
+ * Upload local knowledge files back to the Hub.
1047
+ * Called from deploy/publish so knowledge learned during a session persists.
1048
+ */
1049
+ async function uploadKnowledgeBase(targetDir, baseUrl, projectSlug, apiKey) {
1050
+ const knowledgeDir = path.join(targetDir, ".claude", "knowledge");
1051
+ if (!fs.existsSync(knowledgeDir))
1052
+ return;
1053
+ const files = [];
1054
+ for (const entry of fs.readdirSync(knowledgeDir)) {
1055
+ // Skip INDEX.md (auto-generated) and dotfiles (.knowledge-hashes)
1056
+ if (entry === "INDEX.md" ||
1057
+ entry.startsWith(".") ||
1058
+ !entry.endsWith(".md"))
1059
+ continue;
1060
+ const content = fs.readFileSync(path.join(knowledgeDir, entry), "utf-8");
1061
+ // Extract title from first H1 line, fallback to filename
1062
+ const titleMatch = content.match(/^#\s+(.+)$/m);
1063
+ const title = titleMatch
1064
+ ? titleMatch[1]
1065
+ : entry.replace(/\.md$/, "").replace(/[-_]/g, " ");
1066
+ files.push({ path: entry, title, content });
1067
+ }
1068
+ if (files.length === 0)
1069
+ return;
1070
+ try {
1071
+ const res = await fetch(`${baseUrl}/api/projects/${projectSlug}/knowledge`, {
1072
+ method: "PUT",
1073
+ headers: {
1074
+ Authorization: `Bearer ${apiKey}`,
1075
+ "Content-Type": "application/json",
1076
+ },
1077
+ body: JSON.stringify({ files }),
1078
+ });
1079
+ if (res.ok) {
1080
+ console.log(` Knowledge base synced (${files.length} files).`);
1081
+ }
1082
+ }
1083
+ catch {
1084
+ // Non-fatal
1085
+ }
1086
+ }
865
1087
  /**
866
1088
  * Fetch the latest platform-owned Claude Code rules from the Hub and write
867
1089
  * them into the project's .claude/ directory. This keeps rules like
@@ -869,8 +1091,8 @@ async function syncHubManagedRules(targetDir, baseUrl, projectSlug, apiKey) {
869
1091
  * projects that were scaffolded months ago.
870
1092
  *
871
1093
  * Called from `deploy` (before uploading) and `pull` (after extracting).
872
- * User-owned files (company-context.md, dashboard-context.md) are NOT
873
- * touched — those sync via separate mechanisms.
1094
+ * User-owned files (company-context.md, this-dashboard.md, knowledge/)
1095
+ * are NOT touched — those sync via separate mechanisms.
874
1096
  */
875
1097
  async function syncPlatformRules(targetDir, baseUrl, apiKey) {
876
1098
  try {
@@ -913,18 +1135,22 @@ function scaffoldFromTemplate(templateDir, targetDir, substitutions) {
913
1135
  }
914
1136
  return out;
915
1137
  }
1138
+ const TEMPLATE_RENAMES = {
1139
+ _gitignore: ".gitignore",
1140
+ _overscoreignore: ".overscoreignore",
1141
+ };
916
1142
  function walk(srcDir, destDir) {
917
1143
  fs.mkdirSync(destDir, { recursive: true });
918
1144
  for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
919
1145
  const srcPath = path.join(srcDir, entry.name);
920
- const destName = entry.name === "_gitignore" ? ".gitignore" : entry.name;
1146
+ const destName = TEMPLATE_RENAMES[entry.name] || entry.name;
921
1147
  const destPath = path.join(destDir, destName);
922
1148
  if (entry.isDirectory()) {
923
1149
  walk(srcPath, destPath);
924
1150
  }
925
1151
  else if (entry.isFile()) {
926
1152
  const ext = path.extname(entry.name).toLowerCase();
927
- if (TEXT_EXTENSIONS.has(ext) || entry.name === "_gitignore") {
1153
+ if (TEXT_EXTENSIONS.has(ext) || TEMPLATE_RENAMES[entry.name]) {
928
1154
  const content = fs.readFileSync(srcPath, "utf8");
929
1155
  fs.writeFileSync(destPath, substitute(content));
930
1156
  }
@@ -1011,23 +1237,77 @@ function loadAnalysisMetadata() {
1011
1237
  }
1012
1238
  return JSON.parse(fs.readFileSync(metaPath, "utf-8"));
1013
1239
  }
1014
- const ANALYSIS_PUBLISH_EXCLUDE = new Set([
1240
+ // Always-excluded names these can never be published regardless of
1241
+ // .overscoreignore, so secrets and machine-state can't accidentally ship.
1242
+ const ANALYSIS_PUBLISH_BASELINE_EXCLUDE = new Set([
1015
1243
  "node_modules",
1016
1244
  ".git",
1017
1245
  ".DS_Store",
1018
- ".env",
1019
- "data",
1246
+ ".overscore",
1020
1247
  ]);
1021
- function collectAnalysisFiles(dir, prefix) {
1248
+ function isBaselineExcluded(name) {
1249
+ if (ANALYSIS_PUBLISH_BASELINE_EXCLUDE.has(name))
1250
+ return true;
1251
+ if (name.startsWith(".env"))
1252
+ return true;
1253
+ return false;
1254
+ }
1255
+ function compileIgnorePattern(raw) {
1256
+ let pattern = raw.trim();
1257
+ if (!pattern || pattern.startsWith("#"))
1258
+ return null;
1259
+ const dirOnly = pattern.endsWith("/");
1260
+ if (dirOnly)
1261
+ pattern = pattern.slice(0, -1);
1262
+ // Escape regex specials except for `*`, then turn `*` into `[^/]*`.
1263
+ const escaped = pattern
1264
+ .split("*")
1265
+ .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
1266
+ .join("[^/]*");
1267
+ // Anchor to either a full path match or a path that has the pattern as a
1268
+ // path segment somewhere (so `data/` matches `data/foo.json` and `*.email.html`
1269
+ // matches `emails/x.email.html`).
1270
+ const regex = new RegExp(`(^|/)${escaped}(/|$)`);
1271
+ return { raw, regex, dirOnly };
1272
+ }
1273
+ function loadOverscoreIgnore(dir) {
1274
+ const ignorePath = path.join(dir, ".overscoreignore");
1275
+ if (!fs.existsSync(ignorePath))
1276
+ return [];
1277
+ const content = fs.readFileSync(ignorePath, "utf-8");
1278
+ const patterns = [];
1279
+ for (const line of content.split("\n")) {
1280
+ const compiled = compileIgnorePattern(line);
1281
+ if (compiled)
1282
+ patterns.push(compiled);
1283
+ }
1284
+ return patterns;
1285
+ }
1286
+ function isIgnored(relativePath, isDirectory, patterns) {
1287
+ for (const p of patterns) {
1288
+ if (p.dirOnly && !isDirectory) {
1289
+ // Directory-only pattern can still match a file *inside* the directory.
1290
+ // The regex handles that case for paths like `data/foo.json`.
1291
+ if (p.regex.test(relativePath))
1292
+ return true;
1293
+ }
1294
+ else if (p.regex.test(relativePath)) {
1295
+ return true;
1296
+ }
1297
+ }
1298
+ return false;
1299
+ }
1300
+ function collectAnalysisFiles(dir, prefix, patterns) {
1301
+ const userPatterns = patterns ?? loadOverscoreIgnore(dir);
1022
1302
  const files = [];
1023
1303
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
1024
- if (ANALYSIS_PUBLISH_EXCLUDE.has(entry.name))
1025
- continue;
1026
- if (entry.name.startsWith(".env"))
1304
+ if (isBaselineExcluded(entry.name))
1027
1305
  continue;
1028
1306
  const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
1307
+ if (isIgnored(relativePath, entry.isDirectory(), userPatterns))
1308
+ continue;
1029
1309
  if (entry.isDirectory()) {
1030
- files.push(...collectAnalysisFiles(path.join(dir, entry.name), relativePath));
1310
+ files.push(...collectAnalysisFiles(path.join(dir, entry.name), relativePath, userPatterns));
1031
1311
  }
1032
1312
  else {
1033
1313
  files.push(relativePath);
@@ -1035,7 +1315,61 @@ function collectAnalysisFiles(dir, prefix) {
1035
1315
  }
1036
1316
  return files;
1037
1317
  }
1318
+ function formatBytes(bytes) {
1319
+ if (bytes < 1024)
1320
+ return `${bytes} B`;
1321
+ if (bytes < 1024 * 1024)
1322
+ return `${(bytes / 1024).toFixed(1)} KB`;
1323
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
1324
+ }
1325
+ function summarizeFiles(files, baseDir) {
1326
+ const groupMap = new Map();
1327
+ let totalBytes = 0;
1328
+ for (const rel of files) {
1329
+ const fullPath = path.join(baseDir, rel);
1330
+ let size = 0;
1331
+ try {
1332
+ size = fs.statSync(fullPath).size;
1333
+ }
1334
+ catch {
1335
+ // skip stat failures, count as 0
1336
+ }
1337
+ totalBytes += size;
1338
+ const segments = rel.split("/");
1339
+ const group = segments.length > 1 ? segments[0] + "/" : "(root)";
1340
+ let entry = groupMap.get(group);
1341
+ if (!entry) {
1342
+ entry = { group, files: [], totalBytes: 0 };
1343
+ groupMap.set(group, entry);
1344
+ }
1345
+ entry.files.push({ path: rel, size });
1346
+ entry.totalBytes += size;
1347
+ }
1348
+ // Sort groups: (root) first, then alphabetical
1349
+ const groups = Array.from(groupMap.values()).sort((a, b) => {
1350
+ if (a.group === "(root)")
1351
+ return -1;
1352
+ if (b.group === "(root)")
1353
+ return 1;
1354
+ return a.group.localeCompare(b.group);
1355
+ });
1356
+ return { groups, totalFiles: files.length, totalBytes };
1357
+ }
1358
+ function printBundleSummary(files, baseDir, opts = {}) {
1359
+ const { groups, totalFiles, totalBytes } = summarizeFiles(files, baseDir);
1360
+ for (const group of groups) {
1361
+ const fileWord = group.files.length === 1 ? "file" : "files";
1362
+ console.log(` ${group.group} ${group.files.length} ${fileWord}, ${formatBytes(group.totalBytes)}`);
1363
+ if (opts.verbose) {
1364
+ for (const f of group.files) {
1365
+ console.log(` ${f.path} (${formatBytes(f.size)})`);
1366
+ }
1367
+ }
1368
+ }
1369
+ console.log(` ${"─".repeat(20)}\n Total: ${totalFiles} files, ${formatBytes(totalBytes)}`);
1370
+ }
1038
1371
  async function analysisPublish() {
1372
+ const dryRun = process.argv.includes("--dry-run") || process.argv.includes("--preview");
1039
1373
  const meta = loadAnalysisMetadata();
1040
1374
  if (!meta.project_slug) {
1041
1375
  console.error("\n Error: analysis.json has no project_slug. Check .overscore/analysis.json.\n");
@@ -1045,6 +1379,27 @@ async function analysisPublish() {
1045
1379
  console.error("\n Error: analysis.json has no slug. Check .overscore/analysis.json.\n");
1046
1380
  process.exit(1);
1047
1381
  }
1382
+ const reportPath = path.resolve(process.cwd(), "report.html");
1383
+ if (!fs.existsSync(reportPath)) {
1384
+ console.error("\n Error: report.html not found in this folder. Generate it before publishing.\n");
1385
+ process.exit(1);
1386
+ }
1387
+ const reportContent = fs.readFileSync(reportPath, "utf-8");
1388
+ if (reportContent.includes("This analysis is empty")) {
1389
+ console.error("\n Error: report.html still has placeholder content. Generate the report before publishing.\n");
1390
+ process.exit(1);
1391
+ }
1392
+ // Dry-run: print what would be uploaded, then exit. No network, no auth.
1393
+ if (dryRun) {
1394
+ const files = collectAnalysisFiles(process.cwd(), "");
1395
+ console.log(`\n Dry run — these files would be uploaded for "${meta.title}":\n`);
1396
+ printBundleSummary(files, process.cwd(), { verbose: true });
1397
+ console.log(`
1398
+ Excluded by .overscoreignore + baseline (.git, node_modules, .env*, .overscore/, .DS_Store).
1399
+ No upload made. Re-run without --dry-run to publish.
1400
+ `);
1401
+ return;
1402
+ }
1048
1403
  const cfg = await loadAnalysisConfig();
1049
1404
  // Resolve missing id from the Hub before giving up
1050
1405
  if (!meta.id) {
@@ -1058,16 +1413,6 @@ async function analysisPublish() {
1058
1413
  }
1059
1414
  meta.id = refreshed.id;
1060
1415
  }
1061
- const reportPath = path.resolve(process.cwd(), "report.html");
1062
- if (!fs.existsSync(reportPath)) {
1063
- console.error("\n Error: report.html not found in this folder. Generate it before publishing.\n");
1064
- process.exit(1);
1065
- }
1066
- const reportContent = fs.readFileSync(reportPath, "utf-8");
1067
- if (reportContent.includes("This analysis is empty")) {
1068
- console.error("\n Error: report.html still has placeholder content. Generate the report before publishing.\n");
1069
- process.exit(1);
1070
- }
1071
1416
  console.log(`\n Publishing analysis: ${meta.title}\n`);
1072
1417
  const files = collectAnalysisFiles(process.cwd(), "");
1073
1418
  console.log(` Bundling ${files.length} files...`);
@@ -1091,12 +1436,11 @@ async function analysisPublish() {
1091
1436
  console.error(`\n Publish failed: ${data.error}\n`);
1092
1437
  process.exit(1);
1093
1438
  }
1094
- console.log(`
1095
- Published successfully!
1096
-
1097
- ${data.file_count} files uploaded
1098
- URL: ${data.url}
1099
- `);
1439
+ console.log(`\n Published successfully!\n`);
1440
+ printBundleSummary(files, process.cwd());
1441
+ console.log(`\n URL: ${data.url}\n`);
1442
+ // Upload knowledge base so insights from this analysis persist
1443
+ await uploadKnowledgeBase(process.cwd(), baseUrl, meta.project_slug, cfg.apiKey);
1100
1444
  }
1101
1445
  /**
1102
1446
  * Ensure analysis.json has a valid id by fetching from the Hub.
@@ -1350,8 +1694,30 @@ async function analysisPull(slug) {
1350
1694
  await ensureAnalysisId(targetDir, cfg, analysisSlug);
1351
1695
  }
1352
1696
  if (!inPlace) {
1697
+ // Summarize what landed on disk so the user knows what they got.
1698
+ let pulledFileCount = 0;
1699
+ let pulledBytes = 0;
1700
+ try {
1701
+ const pulledFiles = collectAnalysisFiles(targetDir, "");
1702
+ pulledFileCount = pulledFiles.length;
1703
+ for (const f of pulledFiles) {
1704
+ try {
1705
+ pulledBytes += fs.statSync(path.join(targetDir, f)).size;
1706
+ }
1707
+ catch {
1708
+ // ignore
1709
+ }
1710
+ }
1711
+ }
1712
+ catch {
1713
+ // collection failed — fall back to no summary
1714
+ }
1715
+ const sizeSummary = pulledFileCount > 0
1716
+ ? ` Pulled into ./${analysisSlug} (${pulledFileCount} files, ${formatBytes(pulledBytes)})`
1717
+ : ` Pulled into ./${analysisSlug}`;
1353
1718
  console.log(`
1354
1719
  Pulled "${analysisTitle}"
1720
+ ${sizeSummary}
1355
1721
 
1356
1722
  Next steps:
1357
1723
 
@@ -1367,6 +1733,28 @@ async function analysisPull(slug) {
1367
1733
  `);
1368
1734
  }
1369
1735
  }
1736
+ function analysisPreview() {
1737
+ const reportPath = path.resolve(process.cwd(), "report.html");
1738
+ if (!fs.existsSync(reportPath)) {
1739
+ console.error("\n Error: report.html not found in this folder. Generate it before previewing.\n");
1740
+ process.exit(1);
1741
+ }
1742
+ // Windows `start` needs an empty quoted title before the path arg
1743
+ // (`start "" "C:\path\report.html"`), which is why we prefix it here.
1744
+ const opener = process.platform === "darwin"
1745
+ ? "open"
1746
+ : process.platform === "win32"
1747
+ ? 'start ""'
1748
+ : "xdg-open";
1749
+ try {
1750
+ execSync(`${opener} "${reportPath}"`, { stdio: "inherit" });
1751
+ console.log(`\n Opened ${path.basename(reportPath)} in your default browser.\n`);
1752
+ }
1753
+ catch {
1754
+ console.error(`\n Error: couldn't run "${opener}". Open ${reportPath} manually.\n`);
1755
+ process.exit(1);
1756
+ }
1757
+ }
1370
1758
  // ── auth ────────────────────────────────────────────────────────────
1371
1759
  async function authStatus() {
1372
1760
  const configDir = path.join(process.env.HOME || "", ".overscore");
@@ -1575,19 +1963,26 @@ else if (command === "analysis") {
1575
1963
  else if (subcommand === "pull") {
1576
1964
  analysisPull(process.argv[4]);
1577
1965
  }
1966
+ else if (subcommand === "preview") {
1967
+ analysisPreview();
1968
+ }
1578
1969
  else {
1579
1970
  console.log(`
1580
1971
  overscore analysis — Manage Overscore Analyses (frozen investigations)
1581
1972
 
1582
1973
  Commands:
1583
- new "<title>" Create a new analysis in the current directory
1584
- publish Upload report.html + bundle from the current analysis folder
1585
- pull <slug> Re-download an existing analysis bundle to continue working
1974
+ new "<title>" Create a new analysis in the current directory
1975
+ publish Upload report.html + bundle from the current analysis folder
1976
+ publish --dry-run Print what would be uploaded without sending it
1977
+ pull <slug> Re-download an existing analysis bundle to continue working
1978
+ preview Open report.html in your default browser
1586
1979
 
1587
1980
  Examples:
1588
1981
  npx @overscore/cli analysis new "Why did Q1 churn spike"
1589
1982
  npx @overscore/cli analysis publish
1983
+ npx @overscore/cli analysis publish --dry-run
1590
1984
  npx @overscore/cli analysis pull why-did-q1-churn-spike
1985
+ npx @overscore/cli analysis preview
1591
1986
  `);
1592
1987
  }
1593
1988
  }
@@ -1615,21 +2010,23 @@ else if (command === "query") {
1615
2010
  overscore query — Manage dashboard queries
1616
2011
 
1617
2012
  Commands:
1618
- list List all queries
1619
- show <name> Show a query's SQL and sample data
1620
- add <name> "<sql>" Add a new query
1621
- update <name> "<sql>" Update a query's SQL
1622
- remove <name> Delete a query
1623
- run "<sql>" Run SQL against BigQuery
1624
- test "<sql>" Alias for run
2013
+ list List all queries
2014
+ show <name> Show a query's SQL and sample data
2015
+ add <name> "<sql>" Add a SQL query
2016
+ add <name> --dataset <name> Link an uploaded dataset as a query
2017
+ update <name> "<sql>" Update a query's SQL
2018
+ remove <name> Delete a query
2019
+ run "<sql>" Run SQL against BigQuery
2020
+ run --file <path> Run SQL from a file (e.g. queries/foo.sql)
2021
+ test Alias for run
1625
2022
 
1626
2023
  Examples:
1627
2024
  npx @overscore/cli query list
1628
2025
  npx @overscore/cli query show monthly_revenue
1629
2026
  npx @overscore/cli query add monthly_revenue --file queries/revenue.sql
1630
- npx @overscore/cli query add monthly_revenue "SELECT ..."
1631
2027
  npx @overscore/cli query update monthly_revenue --file queries/revenue.sql
1632
2028
  npx @overscore/cli query run "SELECT * FROM dataset.table LIMIT 10"
2029
+ npx @overscore/cli query run --file queries/q3-cohort-halo.sql
1633
2030
  `);
1634
2031
  }
1635
2032
  }
@@ -1660,9 +2057,12 @@ else {
1660
2057
  npx @overscore/cli query update <name> "<sql>"
1661
2058
  npx @overscore/cli query remove <name>
1662
2059
  npx @overscore/cli query run "<sql>"
2060
+ npx @overscore/cli query run --file queries/foo.sql
1663
2061
  npx @overscore/cli analysis new "<title>"
1664
2062
  npx @overscore/cli analysis publish
2063
+ npx @overscore/cli analysis publish --dry-run
1665
2064
  npx @overscore/cli analysis pull <slug>
2065
+ npx @overscore/cli analysis preview
1666
2066
  npx @overscore/cli install-skills
1667
2067
  `);
1668
2068
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@overscore/cli",
3
- "version": "0.9.14",
3
+ "version": "0.10.1",
4
4
  "description": "CLI for deploying Overscore dashboards and publishing analyses",
5
5
  "bin": {
6
6
  "overscore": "dist/index.js"
@@ -0,0 +1,31 @@
1
+ ---
2
+ name: save-knowledge
3
+ description: Reviews data insights discovered during dashboard or analysis work and proposes additions to the project knowledge base. Use before deploying a dashboard or publishing an analysis, when the user says "save what you learned", or after discovering non-obvious data facts like timezone quirks, column semantics, dedup rules, or metric definitions.
4
+ ---
5
+
6
+ # Save Knowledge
7
+
8
+ Run this skill before deploying a dashboard or when explicitly asked.
9
+
10
+ ## When to invoke
11
+ - Before `npx @overscore/cli deploy`
12
+ - When the user says "save what you learned" or similar
13
+
14
+ ## Process
15
+
16
+ 1. Review the dashboard code and any exploratory queries you ran.
17
+
18
+ 2. Read `.claude/knowledge/INDEX.md` to see what's already documented.
19
+
20
+ 3. For each new insight, decide:
21
+ - **Project knowledge** (helps ALL future artifacts) → propose addition to `.claude/knowledge/`
22
+ - **Dashboard-specific** (design decision, query note) → append to `.claude/rules/this-dashboard.md`
23
+ - **Already known** → skip
24
+
25
+ 4. Present proposed additions to the user for approval before writing any files.
26
+
27
+ ## What qualifies as project knowledge vs dashboard-specific
28
+ - "dates are UTC-7 no DST" → project knowledge (helps all artifacts)
29
+ - "affiliate_id = 0 is unattributed" → project knowledge
30
+ - "Revenue chart excludes refunds per Finance definition" → dashboard-specific
31
+ - "Default filter is 90 days for CFO's current-quarter view" → dashboard-specific
@@ -1,6 +1,8 @@
1
1
  ---
2
2
  name: scaffold-overscore-artifact
3
- description: Use when the user wants to create a new Overscore dashboard or analysis, or when they're inside one artifact type and want to switch to the other (e.g. "turn this analysis into a dashboard", "do a quick analysis on this dashboard's data", "I want to investigate why X happened", "I want to track X over time"). Follow this procedure to scaffold the new artifact in the correct location with the correct command and seed it with context from the current artifact.
3
+ description: Routes users to the correct Overscore artifact type (Dashboard vs Analysis) and scaffolds it in the right location. Dashboards track metrics forever with live data; Analyses answer a question once with a static HTML report.
4
+ when_to_use: Use when the user wants to create a new dashboard or analysis, or when they're inside one artifact type and want to switch to the other. Trigger phrases include "build a dashboard", "track X over time", "create an analysis", "investigate why X", "turn this into a dashboard", "do a quick analysis", "I want to monitor", "I need a report on".
5
+ argument-hint: "[dashboard or analysis description]"
4
6
  ---
5
7
 
6
8
  # Scaffolding Overscore Artifacts
@@ -31,6 +31,12 @@ Use the Overscore CLI to run SQL against the project's BigQuery connection:
31
31
  npx @overscore/cli query run "SELECT col1, COUNT(*) FROM \`project.dataset.table\` WHERE date >= '2024-01-01' GROUP BY 1 LIMIT 100"
32
32
  ```
33
33
 
34
+ For longer queries, write the SQL to a file in `queries/` and pass `--file`:
35
+
36
+ ```bash
37
+ npx @overscore/cli query run --file queries/q3-cohort-halo.sql
38
+ ```
39
+
34
40
  The CLI authenticates via `~/.overscore/config` (set up on first use). Credentials are stored in the Hub — never hardcode them locally.
35
41
 
36
42
  **Rules for exploratory queries:**
@@ -39,24 +45,46 @@ The CLI authenticates via `~/.overscore/config` (set up on first use). Credentia
39
45
  - Pre-aggregate before pulling wide result sets
40
46
  - Do NOT use `query list`, `query add`, or `query show` — those are for dashboard registered queries, not analysis exploration
41
47
 
42
- **Previous query results are saved automatically.** Every `query run` saves the full result set to `data/` as a JSON file. Before re-running a query, check `data/` — the results may already be there from a previous session. Read them with standard file tools instead of re-querying BigQuery.
48
+ **Previous query results are saved automatically.** Every `query run` saves the full result set to `data/` as a JSON file, keyed by a hash of the SQL. Re-running an identical query is a no-op — the CLI prints `Cached (already saved earlier): data/...` instead of writing a duplicate. Before re-running a query, check `data/` — the results may already be there from a previous session. Read them with standard file tools instead of re-querying BigQuery.
49
+
50
+ ## Saving final queries
51
+
52
+ The `queries/` directory is for SQL files you want to keep around — the canonical version of each Q-numbered query in `analysis-log.md`, queries the user might want to re-run later, or anything you've iterated on enough to deserve a name. File names like `q3-cohort-halo.sql` cross-reference the Query Log. Exploratory one-offs don't need to live here; they're already saved as JSON in `data/`.
43
53
 
44
- ## Where data context lives
54
+ ## Project Knowledge Base
45
55
 
46
- `.claude/rules/company-context.md` was pulled from this analysis's parent project at scaffold time. It contains the project's table definitions, business logic, and data quirks shared across all dashboards and analyses in this project. **Read it first** before exploring blindly.
56
+ `.claude/knowledge/INDEX.md` lists all knowledge files for this project table definitions,
57
+ business logic, metric formulas, data quirks. **Read INDEX.md before starting any data work.**
58
+ Only read the specific files relevant to your current task.
59
+
60
+ When you discover something non-obvious (a timezone gotcha, a dedup rule, a column that means
61
+ something unexpected), **propose adding it to the knowledge base**:
62
+ - Create a new `.claude/knowledge/{topic}.md` file (one topic per file, 20-80 lines)
63
+ - Or append to an existing file if the topic matches
64
+ - Update INDEX.md when you add or remove a file
65
+
66
+ Knowledge files sync to the Hub on `analysis publish` and are shared with every artifact
67
+ in this project. Good knowledge notes help every future session.
47
68
 
48
69
  ## Previewing
49
70
 
50
71
  To preview the report at any point, open it in the browser:
51
72
 
52
73
  ```bash
53
- open report.html # macOS
54
- start report.html # Windows
55
- xdg-open report.html # Linux
74
+ npx @overscore/cli analysis preview
56
75
  ```
57
76
 
77
+ (Or `open report.html` / `start report.html` / `xdg-open report.html` if the CLI isn't handy.)
78
+
58
79
  Preview early and often — don't write the entire report before checking the layout. Build section by section, previewing after each major addition.
59
80
 
81
+ ## If the user wants a share email or Slack message
82
+
83
+ If the user asks for a copy-paste email or Slack summary of the analysis, write it to a file like `update-YYYY-MM-DD.email.html` in the analysis root. Two things to know:
84
+
85
+ - **Email-safe HTML only.** Gmail and most clients strip `<style>` blocks — use inline styles on every element. No `<script>` (most clients block it). Keep layouts simple; complex things (multi-column) want `<table>` for cross-client compatibility.
86
+ - **The `.email.html` suffix keeps it out of the published bundle.** It's matched by `.overscoreignore`, so iterating on email drafts won't pollute the public report.
87
+
60
88
  ## Publishing
61
89
 
62
90
  When the analysis is ready, **tell the user to run** (do not run it yourself without asking):
@@ -79,4 +107,4 @@ The following files in `.claude/rules/` load automatically when you touch matchi
79
107
  - **`report-html.md`** — Self-contained HTML rules and dark-theme spec (loads when editing `report.html`)
80
108
  - **`report-components.md`** — Copy-paste HTML/SVG component snippets: KPI cards, charts, tables, heatmaps (loads when editing `report.html`)
81
109
  - **`analysis-methodology.md`** — Investigation loop, when to ask vs query (loads when editing `analysis-log.md`)
82
- - **`company-context.md`** — Project-level data context (loaded every session)
110
+ - **`knowledge/INDEX.md`** — Project knowledge base catalog (read before data work)
@@ -77,4 +77,8 @@ When starting `report.html`, read only the **Key Findings** section of `analysis
77
77
 
78
78
  ## Cached query results
79
79
 
80
- Every `query run` command saves its result to `data/{hash}_{snippet}.json`. At the start of a session, check `data/` for existing results before re-running queries — especially after a context reset or `analysis pull`. The JSON files contain the SQL, timestamp, columns, and full row data. Read them directly instead of re-querying BigQuery.
80
+ Every `query run` command saves its result to `data/{hash}_{snippet}.json`. The hash is derived from the SQL string, so re-running an identical query is a no-op — the CLI prints `Cached (already saved earlier): data/...` instead of writing a duplicate. At the start of a session, check `data/` for existing results before re-running queries — especially after a context reset or `analysis pull`. The JSON files contain the SQL, timestamp, columns, and full row data. Read them directly instead of re-querying BigQuery.
81
+
82
+ ## Saving queries to disk
83
+
84
+ For any query worth referencing later (each Q-numbered query in this log, queries the user might re-run, longer queries you've iterated on), save the SQL to `queries/qN-short-name.sql` and run it via `npx @overscore/cli query run --file queries/qN-short-name.sql`. The Query Log entry then just needs the file reference instead of a full SQL paste — it keeps the log readable and gives you a single source of truth to edit. Throwaway one-shot exploratory queries don't need a file; their JSON in `data/` is enough.
@@ -80,4 +80,11 @@ A good analysis report has these sections in order:
80
80
  </html>
81
81
  ```
82
82
 
83
+ **Mobile responsiveness.** Reports are often opened on phones from Slack links. The skeleton's `max-width: 900px` and viewport meta tag handle the basics, but also:
84
+ - Use `clamp()` or media queries for KPI font sizes (`font-size: clamp(1.5rem, 5vw, 2.5rem)`) so they don't overflow on 375px screens.
85
+ - KPI card grids should wrap: `display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr))`.
86
+ - Tables need `overflow-x: auto` on their container.
87
+ - Padding: `2rem` is fine on desktop but use `padding: 2rem 1rem` on mobile via `@media (max-width: 480px)`.
88
+ - Charts (SVG): set `width="100%" viewBox="..."` and let them scale, or use `max-width: 100%`.
89
+
83
90
  **Always preview `report.html` in a browser and get the user's approval before publishing.**
@@ -0,0 +1,38 @@
1
+ ---
2
+ name: save-knowledge
3
+ description: Reviews data insights discovered during analysis work and proposes additions to the project knowledge base. Use before publishing an analysis, when the user says "save what you learned", or after discovering non-obvious data facts like timezone quirks, column semantics, dedup rules, or metric definitions.
4
+ ---
5
+
6
+ # Save Knowledge
7
+
8
+ Run this skill before publishing an analysis or when explicitly asked.
9
+
10
+ ## When to invoke
11
+ - Before `analysis publish` (review what you learned first)
12
+ - When the user says "save what you learned" or similar
13
+
14
+ ## Process
15
+
16
+ 1. Review `analysis-log.md` — look for non-obvious data facts you discovered:
17
+ - Column semantics that weren't obvious from the name
18
+ - Timezone, dedup, or join gotchas
19
+ - Metric definitions or business rules
20
+ - Data quality issues or known gaps
21
+
22
+ 2. Read `.claude/knowledge/INDEX.md` to see what's already documented.
23
+
24
+ 3. For each new insight, decide:
25
+ - **Project knowledge** (helps ALL future artifacts) → propose a new file or addition to `.claude/knowledge/`
26
+ - **Already known** (in an existing knowledge file) → skip
27
+ - **Analysis-specific** (only relevant to this investigation) → leave in analysis-log.md
28
+
29
+ 4. Present proposed additions to the user for approval before writing any files.
30
+
31
+ 5. After approval, write the files and update INDEX.md.
32
+
33
+ ## What qualifies as project knowledge
34
+ - "unique_page_views is already COUNT(DISTINCT domain_userid)" → YES
35
+ - "date_az is America/Phoenix UTC-7, no DST" → YES
36
+ - "affiliate_id = 0 is unattributed, usually exclude" → YES
37
+ - "Q1 churn spiked because of the pricing change" → NO (analysis conclusion, not data fact)
38
+ - "I used a 90-day window for this analysis" → NO (analysis-specific decision)
@@ -1,6 +1,11 @@
1
1
  ---
2
2
  name: start-analysis
3
- description: Use at the start of any new Overscore analysis BEFORE querying data. Interviews the user about the question, known data sources, success criteria, and hypotheses, then writes an initial analysis-log.md entry and proposes a query plan for approval. Trigger when analysis-log.md has no queries logged yet.
3
+ description: Interviews the user before querying data at the start of a new Overscore analysis. Captures the real question, known tables, success criteria, hypotheses, time window, and data quirks, then writes an initial analysis-log.md entry and proposes a query plan for approval. Use when analysis-log.md has no queries logged yet, or when the user says "investigate", "look into", or "analyze" something for the first time.
4
+ allowed-tools:
5
+ - Read
6
+ - Write
7
+ - Edit
8
+ - Bash(npx @overscore/cli query run *)
4
9
  ---
5
10
 
6
11
  # Starting an analysis the right way
@@ -32,7 +37,7 @@ Ask these one or two at a time conversationally — don't dump all 6 at once lik
32
37
 
33
38
  ## After the interview
34
39
 
35
- 1. **Read `.claude/rules/company-context.md`** to cross-reference what the user said against what's already documented about the project's data.
40
+ 1. **Read `.claude/knowledge/INDEX.md`** and any relevant knowledge files to understand what previous sessions have already documented about this project's data. Cross-reference what the user said against what's already known.
36
41
  2. **Draft an initial entry in `analysis-log.md`** with:
37
42
  - The clarified question (not the original title)
38
43
  - The tables the user named, with what each contains
@@ -51,4 +56,4 @@ Ask these one or two at a time conversationally — don't dump all 6 at once lik
51
56
 
52
57
  ## If the user resists the interview
53
58
 
54
- Some users will say "just look at the data, I'll tell you if it's wrong." That's a trap — you'll spend 30 minutes probing tables and still need the interview at the end. Push back once: "I can save us a lot of time if you tell me what tables matter and what success looks like — it's a 2-minute conversation." If they still refuse, do a single sizing pass on the parent project's known tables (from `company-context.md`), then come back and ask narrower questions based on what you found.
59
+ Some users will say "just look at the data, I'll tell you if it's wrong." That's a trap — you'll spend 30 minutes probing tables and still need the interview at the end. Push back once: "I can save us a lot of time if you tell me what tables matter and what success looks like — it's a 2-minute conversation." If they still refuse, do a single sizing pass on the parent project's known tables (from the knowledge base), then come back and ask narrower questions based on what you found.
@@ -0,0 +1,12 @@
1
+ # Files matching these patterns are excluded from `analysis publish`.
2
+ # Syntax: one pattern per line. `*` is a glob within a single path segment.
3
+ # Trailing `/` matches a directory and everything inside it.
4
+ # Lines starting with `#` are comments.
5
+ #
6
+ # A baseline set is always excluded regardless of this file:
7
+ # .git/, node_modules/, .DS_Store, .env*, .overscore/
8
+
9
+ data/
10
+ *.draft.html
11
+ *.email.html
12
+ .knowledge-hashes
File without changes