@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 +497 -97
- package/package.json +1 -1
- package/skills/save-knowledge/SKILL.md +31 -0
- package/skills/scaffold-overscore-artifact/SKILL.md +3 -1
- package/templates/analysis/.claude/CLAUDE.md +35 -7
- package/templates/analysis/.claude/rules/analysis-methodology.md +5 -1
- package/templates/analysis/.claude/rules/report-html.md +7 -0
- package/templates/analysis/.claude/skills/save-knowledge/SKILL.md +38 -0
- package/templates/analysis/.claude/skills/start-analysis/SKILL.md +8 -3
- package/templates/analysis/_overscoreignore +12 -0
- package/templates/analysis/queries/.gitkeep +0 -0
package/dist/index.js
CHANGED
|
@@ -47,7 +47,29 @@ function loadEnv() {
|
|
|
47
47
|
};
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
|
-
|
|
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
|
-
//
|
|
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
|
|
286
|
-
|
|
287
|
-
|
|
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
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
console.log(`
|
|
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
|
|
395
|
+
console.log(` ~${query.row_count.toLocaleString()} rows`);
|
|
347
396
|
}
|
|
348
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
438
|
-
return
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
console.error(
|
|
448
|
-
console.error(
|
|
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
|
-
|
|
453
|
-
|
|
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 (
|
|
457
|
-
console.log(
|
|
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 (
|
|
485
|
-
console.log(`\n
|
|
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
|
|
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
|
-
//
|
|
837
|
-
//
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
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
|
-
|
|
848
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
".
|
|
1019
|
-
"data",
|
|
1246
|
+
".overscore",
|
|
1020
1247
|
]);
|
|
1021
|
-
function
|
|
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 (
|
|
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
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
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>"
|
|
1584
|
-
publish
|
|
1585
|
-
|
|
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
|
|
1619
|
-
show <name>
|
|
1620
|
-
add <name> "<sql>"
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
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
|
@@ -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:
|
|
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
|
-
##
|
|
54
|
+
## Project Knowledge Base
|
|
45
55
|
|
|
46
|
-
`.claude/
|
|
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
|
-
|
|
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
|
-
- **`
|
|
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:
|
|
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/
|
|
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
|
|
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
|