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 +1 -1
- package/src/cli/index.js +279 -15
- package/src/core/compliance.js +1 -1
- package/src/core/guardian.js +466 -457
- package/src/core/hooks.js +109 -91
- package/src/core/semantics.js +3019 -2717
- package/src/core/telemetry.js +940 -852
- package/src/dashboard/index.html +2 -2
- package/src/mcp/http-server.js +1 -1
- package/src/mcp/server.js +1 -1
package/package.json
CHANGED
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.
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
}
|
package/src/core/compliance.js
CHANGED