speclock 5.5.6 → 5.5.7

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/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  "name": "speclock",
4
4
 
5
- "version": "5.5.6",
5
+ "version": "5.5.7",
6
6
 
7
7
  "mcpName": "io.github.sgroy10/speclock",
8
8
 
package/src/cli/index.js CHANGED
@@ -68,6 +68,7 @@ import {
68
68
  ensureTelemetryDecision,
69
69
  recordCommand,
70
70
  TELEMETRY_DEFAULT_ENDPOINT,
71
+ buildUsageStats,
71
72
  } from "../core/telemetry.js";
72
73
  import {
73
74
  isSSOEnabled,
@@ -286,7 +287,7 @@ export function initFromRulePack(root, framework) {
286
287
 
287
288
  function printHelp() {
288
289
  console.log(`
289
- SpecLock v5.5.6 — Your AI has rules. SpecLock makes them unbreakable.
290
+ SpecLock v5.5.7 — Your AI has rules. SpecLock makes them unbreakable.
290
291
  Developed by Sandeep Roy (github.com/sgroy10)
291
292
 
292
293
  Usage: speclock <command> [options]
@@ -340,6 +341,7 @@ Commands:
340
341
  watch Start file watcher (live dashboard)
341
342
  serve [--project <path>] Start MCP stdio server
342
343
  status Show project brain summary
344
+ stats Show YOUR usage dashboard from local telemetry log
343
345
  doctor Diagnostic health check (install, git, rules, MCP)
344
346
 
345
347
  Options:
@@ -442,6 +444,152 @@ function showStatus(root) {
442
444
  console.log("");
443
445
  }
444
446
 
447
+ // --- Stats dashboard (speclock stats) ---
448
+
449
+ /**
450
+ * Build a self-contained view-model for the `speclock stats` dashboard.
451
+ * Pure data — no console I/O — so tests can assert against it.
452
+ *
453
+ * Combines three sources:
454
+ * 1. Local telemetry log (~/.speclock/telemetry.jsonl) via buildUsageStats
455
+ * 2. Current project brain.json (enforcement mode, lock count)
456
+ * 3. Rule file discovery + MCP client detection (from a sample telemetry event)
457
+ *
458
+ * Falls back gracefully when telemetry is disabled or the log is missing —
459
+ * the "Current State" section is still populated from brain.json.
460
+ *
461
+ * @param {string} root - project root
462
+ * @param {object} [opts] - passed through to buildUsageStats (e.g. { events, now })
463
+ */
464
+ export function buildStatsView(root, opts = {}) {
465
+ const usage = buildUsageStats(opts);
466
+
467
+ let lockCount = 0;
468
+ let enforcementMode = "unknown";
469
+ let brainExists = false;
470
+ try {
471
+ const brain = readBrain(root);
472
+ if (brain) {
473
+ brainExists = true;
474
+ const items = brain.specLock && Array.isArray(brain.specLock.items)
475
+ ? brain.specLock.items
476
+ : [];
477
+ lockCount = items.filter((l) => l && l.active !== false).length;
478
+ const cfg = getEnforcementConfig(brain);
479
+ // Map internal ("advisory" | "hard") to user-facing ("warn" | "hard").
480
+ enforcementMode = cfg.mode === "hard" ? "hard" : "warn";
481
+ }
482
+ } catch (_) { /* swallow */ }
483
+
484
+ // Rule files (from the same list guardian.js uses).
485
+ let ruleFiles = [];
486
+ try {
487
+ const discovered = discoverRuleFiles(root);
488
+ ruleFiles = discovered.map((f) => f.file);
489
+ } catch (_) { /* swallow */ }
490
+
491
+ // MCP clients — reuse the detector embedded in the sample telemetry event.
492
+ let mcpClients = [];
493
+ try {
494
+ const sample = getOptInTelemetryStatus({ eventLimit: 0 }).sampleEvent;
495
+ if (sample && Array.isArray(sample.mcpClientsConfigured)) {
496
+ mcpClients = sample.mcpClientsConfigured;
497
+ }
498
+ } catch (_) { /* swallow */ }
499
+
500
+ return {
501
+ ...usage,
502
+ brainExists,
503
+ enforcementMode,
504
+ lockCount,
505
+ ruleFiles,
506
+ mcpClients,
507
+ };
508
+ }
509
+
510
+ /**
511
+ * Render the stats view-model as a human-readable dashboard string.
512
+ * Kept separate from console output so tests can assert on the rendered
513
+ * text without capturing stdout.
514
+ */
515
+ export function formatStatsDashboard(view) {
516
+ const lines = [];
517
+ const firstInstallDate = view.firstInstallIso
518
+ ? view.firstInstallIso.slice(0, 10)
519
+ : "(unknown)";
520
+ const installIdShort = view.installId && view.installId !== "unknown"
521
+ ? view.installId.slice(0, 8) + "..."
522
+ : "(none)";
523
+
524
+ lines.push("");
525
+ lines.push("SpecLock Stats — Your Usage");
526
+ lines.push("=".repeat(32));
527
+ lines.push("");
528
+ lines.push("Installation");
529
+ lines.push(` First install: ${firstInstallDate}`);
530
+ lines.push(` Days active: ${view.daysActive}`);
531
+ lines.push(` Total events: ${view.totalEvents}`);
532
+ lines.push(` Install ID: ${installIdShort}`);
533
+ lines.push("");
534
+
535
+ lines.push("Commands Used");
536
+ const entries = Object.entries(view.commandsByType).sort(
537
+ ([, a], [, b]) => b - a
538
+ );
539
+ if (entries.length === 0) {
540
+ if (view.telemetryEnabled) {
541
+ lines.push(" (no events recorded yet — run some commands!)");
542
+ } else {
543
+ lines.push(" (telemetry disabled — enable with 'speclock telemetry on' to track usage)");
544
+ }
545
+ } else {
546
+ const maxName = Math.max(...entries.map(([n]) => n.length));
547
+ for (const [name, count] of entries) {
548
+ lines.push(` ${(name + ":").padEnd(maxName + 2)}${count}`);
549
+ }
550
+ }
551
+ lines.push("");
552
+
553
+ lines.push("Current State");
554
+ lines.push(` Enforcement: ${view.enforcementMode}`);
555
+ lines.push(` Locks: ${view.lockCount}`);
556
+ if (view.ruleFiles.length > 0) {
557
+ lines.push(` Rule files: ${view.ruleFiles.length} (${view.ruleFiles.join(", ")})`);
558
+ } else {
559
+ lines.push(" Rule files: 0");
560
+ }
561
+ if (view.mcpClients.length > 0) {
562
+ lines.push(` MCP clients: ${view.mcpClients.join(", ")}`);
563
+ } else {
564
+ lines.push(" MCP clients: (none detected)");
565
+ }
566
+ lines.push("");
567
+
568
+ lines.push(`Recent Activity (last ${view.recentEvents.length})`);
569
+ if (view.recentEvents.length === 0) {
570
+ lines.push(" (no activity recorded)");
571
+ } else {
572
+ // Most recent first in the dashboard.
573
+ const sorted = view.recentEvents.slice().reverse();
574
+ for (const e of sorted) {
575
+ const ts = (e.timestamp || "").replace("T", " ").slice(0, 16);
576
+ const cmd = (e.command || "unknown").padEnd(10);
577
+ const exit = typeof e.exitCode === "number" ? e.exitCode : "?";
578
+ lines.push(` ${ts} ${cmd} exit ${exit}`);
579
+ }
580
+ }
581
+ lines.push("");
582
+
583
+ if (!view.telemetryEnabled) {
584
+ lines.push("Note: telemetry is DISABLED — stats above reflect any pre-existing log");
585
+ lines.push(" plus the current project state from .speclock/brain.json.");
586
+ }
587
+ lines.push("Tip: Run 'speclock telemetry status' to see telemetry settings");
588
+ lines.push("");
589
+
590
+ return lines.join("\n");
591
+ }
592
+
445
593
  // --- Main ---
446
594
 
447
595
  async function main() {
@@ -491,9 +639,16 @@ async function main() {
491
639
  console.log(`Created SPECLOCK.md (AI instructions file).`);
492
640
 
493
641
  // 4. Inject marker into package.json (so AI tools auto-discover SpecLock)
494
- const pkgResult = injectPackageJsonMarker(root);
495
- if (pkgResult.success) {
496
- console.log("Injected SpecLock marker into package.json.");
642
+ // — only when package.json actually exists. We do NOT create one.
643
+ const pkgJsonPath = path.join(root, "package.json");
644
+ const pkgJsonExistedBefore = fs.existsSync(pkgJsonPath);
645
+ let pkgJsonUpdated = false;
646
+ if (pkgJsonExistedBefore) {
647
+ const pkgResult = injectPackageJsonMarker(root);
648
+ if (pkgResult.success) {
649
+ pkgJsonUpdated = true;
650
+ console.log("Injected SpecLock marker into package.json.");
651
+ }
497
652
  }
498
653
 
499
654
  // 5. Apply template if specified
@@ -511,6 +666,9 @@ async function main() {
511
666
  console.log("Generated .speclock/context/latest.md");
512
667
 
513
668
  // 7. Print summary
669
+ const pkgJsonLine = pkgJsonUpdated
670
+ ? " package.json — Active locks embedded (AI auto-discovery)"
671
+ : " package.json (skipped — not found)";
514
672
  console.log(`
515
673
  SpecLock is ready!
516
674
 
@@ -518,7 +676,7 @@ Files created/updated:
518
676
  .speclock/brain.json — Project memory
519
677
  .speclock/context/latest.md — Context for AI (read this)
520
678
  SPECLOCK.md — AI rules (read this)
521
- package.json — Active locks embedded (AI auto-discovery)
679
+ ${pkgJsonLine}
522
680
 
523
681
  Next steps:
524
682
  To add constraints: npx speclock lock "Never touch auth files"
@@ -1355,16 +1513,52 @@ Tip: Run "speclock sync --all" to push constraints to Cursor, Claude, Copilot, W
1355
1513
  : `WARNING: ${result.violations.length} violation(s) detected. Review before proceeding.`;
1356
1514
  }
1357
1515
 
1358
- // Warn mode default: only exit 1 if --strict, SPECLOCK_STRICT=1, or brain is in "hard" mode
1359
- // (result.blocked already reflects "hard" mode from brain config).
1360
- const strict =
1361
- flags.strict === true ||
1362
- flags.block === true ||
1516
+ // Warn mode default: only exit 1 if --strict, SPECLOCK_STRICT=1, or brain is in "hard" mode.
1517
+ //
1518
+ // Enforcement mode precedence (first match wins):
1519
+ // 1. --strict / --block CLI flag
1520
+ // 2. SPECLOCK_STRICT=1 env var
1521
+ // 3. brain.enforcement.mode === "hard" from .speclock/brain.json
1522
+ // 4. Default: warn mode (exit 0)
1523
+ //
1524
+ // We re-read brain.json here as a belt-and-braces fallback because
1525
+ // semanticAudit() may early-return (no staged diff, no locks, brain
1526
+ // missing) WITHOUT setting result.mode/result.blocked, and git hooks
1527
+ // can run in sanitized environments where SPECLOCK_STRICT=1 on the
1528
+ // `git commit` command line gets stripped by some shells. The
1529
+ // persistent brain mode (set by `speclock enforce hard`) is the only
1530
+ // reliable way to enforce hard blocking across all git/shell combos.
1531
+ const cliStrict = flags.strict === true || flags.block === true;
1532
+ const envStrict =
1363
1533
  process.env.SPECLOCK_STRICT === "1" ||
1364
- process.env.SPECLOCK_STRICT === "true" ||
1365
- result.blocked;
1534
+ process.env.SPECLOCK_STRICT === "true";
1535
+
1536
+ let brainHardMode = false;
1537
+ try {
1538
+ const brainForMode = readBrain(root);
1539
+ if (brainForMode) {
1540
+ const cfg = getEnforcementConfig(brainForMode);
1541
+ brainHardMode = cfg.mode === "hard";
1542
+ }
1543
+ } catch { /* brain unreadable — treat as advisory */ }
1544
+
1545
+ // If brain is in hard mode but semanticAudit() returned without
1546
+ // reflecting that (early-return path), retro-fit the result so the
1547
+ // downstream printing + blocking decision stays consistent.
1548
+ if (brainHardMode && result.mode !== "hard") {
1549
+ result.mode = "hard";
1550
+ if (result.threshold === undefined) result.threshold = 70;
1551
+ }
1552
+ if (brainHardMode && !result.blocked) {
1553
+ const thresh = result.threshold || 70;
1554
+ result.blocked = (result.violations || []).some(
1555
+ (v) => (v.confidence || 0) >= thresh
1556
+ );
1557
+ }
1558
+
1559
+ const strict = cliStrict || envStrict || brainHardMode || result.blocked;
1366
1560
 
1367
- // --- Three-tier output filter (v5.5.6) ---
1561
+ // --- Three-tier output filter (v5.5.7) ---
1368
1562
  // Investor audit: walls of LOW-confidence matches are user-hostile.
1369
1563
  // Only HIGH and MEDIUM print by default. LOW rolls up into a one-liner.
1370
1564
  // --verbose / SPECLOCK_VERBOSE=1 shows everything.
@@ -1629,6 +1823,18 @@ Tip: Run "speclock sync --all" to push constraints to Cursor, Claude, Copilot, W
1629
1823
  process.exit(1);
1630
1824
  }
1631
1825
 
1826
+ // --- STATS (user-facing usage dashboard) ---
1827
+ if (cmd === "stats") {
1828
+ try {
1829
+ const view = buildStatsView(root);
1830
+ console.log(formatStatsDashboard(view));
1831
+ } catch (err) {
1832
+ console.error(`Failed to build stats: ${err.message}`);
1833
+ process.exit(1);
1834
+ }
1835
+ return;
1836
+ }
1837
+
1632
1838
  // --- TELEMETRY (opt-in, v5.5) ---
1633
1839
  if (cmd === "telemetry") {
1634
1840
  const sub = args[0];
@@ -2053,21 +2259,79 @@ Tip: Run "speclock sync --all" to push constraints to Cursor, Claude, Copilot, W
2053
2259
  lines.push("");
2054
2260
 
2055
2261
  // --- 3. Rule Files ---
2262
+ // Doctor checks both:
2263
+ // (a) ORIGINAL rule files (.cursorrules, CLAUDE.md, etc.) that users author
2264
+ // (b) SYNCED files written by `speclock protect` / `speclock sync`
2265
+ // (.cursor/rules/speclock.mdc, .windsurf/rules/speclock.md, AGENTS.md
2266
+ // with SpecLock marker, GEMINI.md, .github/copilot-instructions.md,
2267
+ // .aider.conf.yml)
2268
+ // A file is considered "synced" if it has a SpecLock auto-gen marker in its header.
2056
2269
  lines.push("Rule Files");
2270
+
2271
+ // All the files `speclock sync` can produce (mirrors FORMATS in rules-sync.js)
2272
+ const SYNCED_OUTPUT_FILES = [
2273
+ ".cursor/rules/speclock.mdc",
2274
+ ".windsurf/rules/speclock.md",
2275
+ ".github/copilot-instructions.md",
2276
+ "GEMINI.md",
2277
+ ".aider.conf.yml",
2278
+ "AGENTS.md",
2279
+ ];
2280
+
2281
+ // Markers that indicate a file was written by SpecLock's sync pipeline.
2282
+ // Must match the markers used by isSpeclockGenerated() in guardian.js.
2283
+ const SPECLOCK_DOCTOR_SYNC_MARKERS = [
2284
+ "Auto-synced from SpecLock",
2285
+ "Auto-synced by SpecLock",
2286
+ "Auto-synced.",
2287
+ "(SpecLock)",
2288
+ "# SpecLock Constraints",
2289
+ "Do not edit manually — run `speclock sync`",
2290
+ "speclock sync --format",
2291
+ "speclock_session_briefing",
2292
+ ];
2293
+
2294
+ function isSyncedFile(absPath) {
2295
+ try {
2296
+ const content = fs.readFileSync(absPath, "utf-8");
2297
+ const header = content.split("\n").slice(0, 10).join("\n");
2298
+ return SPECLOCK_DOCTOR_SYNC_MARKERS.some((m) => header.includes(m));
2299
+ } catch (_) {
2300
+ return false;
2301
+ }
2302
+ }
2303
+
2057
2304
  const discovered = discoverRuleFiles(root);
2058
2305
  const discoveredMap = new Map(discovered.map((f) => [f.file, f]));
2306
+ const shownFiles = new Set();
2059
2307
  let totalRuleFilesFound = 0;
2308
+
2309
+ // (a) Original/authored rule files
2060
2310
  for (const entry of RULE_FILES) {
2061
2311
  const found = discoveredMap.get(entry.file);
2062
2312
  if (found) {
2063
2313
  const extracted = extractConstraints(found.content, found.file);
2064
2314
  lines.push(` ✓ ${entry.file} (${extracted.locks.length} locks extracted)`);
2315
+ shownFiles.add(entry.file);
2065
2316
  totalRuleFilesFound++;
2066
- } else {
2067
- lines.push(` ✗ ${entry.file} (not found)`);
2068
2317
  }
2069
2318
  }
2319
+
2320
+ // (b) Synced files written by `speclock protect` / `speclock sync`
2321
+ for (const relPath of SYNCED_OUTPUT_FILES) {
2322
+ if (shownFiles.has(relPath)) continue; // already shown as an authored file
2323
+ const abs = path.join(root, relPath);
2324
+ if (!fs.existsSync(abs)) continue;
2325
+ if (isSyncedFile(abs)) {
2326
+ lines.push(` ✓ ${relPath} (synced)`);
2327
+ shownFiles.add(relPath);
2328
+ totalRuleFilesFound++;
2329
+ }
2330
+ }
2331
+
2332
+ // If nothing at all was found, show a clear diagnostic.
2070
2333
  if (totalRuleFilesFound === 0) {
2334
+ lines.push(` ✗ No rule files found`);
2071
2335
  fixes.push("Run: speclock protect (auto-creates a starter CLAUDE.md)");
2072
2336
  issueCount++;
2073
2337
  }
@@ -9,7 +9,7 @@
9
9
  import { readBrain, readEvents } from "./storage.js";
10
10
  import { verifyAuditChain } from "./audit.js";
11
11
 
12
- const VERSION = "5.5.6";
12
+ const VERSION = "5.5.7";
13
13
 
14
14
  // PHI-related keywords for HIPAA filtering
15
15
  const PHI_KEYWORDS = [