speclock 5.5.4 → 5.5.5

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/src/cli/index.js CHANGED
@@ -1,4 +1,7 @@
1
+ import fs from "fs";
1
2
  import path from "path";
3
+ import { fileURLToPath } from "url";
4
+ import { getStagedDiff, parseDiff } from "../core/pre-commit-semantic.js";
2
5
  import {
3
6
  ensureInit,
4
7
  setGoal,
@@ -56,6 +59,15 @@ import {
56
59
  import {
57
60
  isTelemetryEnabled,
58
61
  getTelemetrySummary,
62
+ isTelemetryOptedIn,
63
+ hasTelemetryDecision,
64
+ enableTelemetry,
65
+ disableTelemetry,
66
+ clearTelemetryLog,
67
+ getOptInTelemetryStatus,
68
+ ensureTelemetryDecision,
69
+ recordCommand,
70
+ TELEMETRY_DEFAULT_ENDPOINT,
59
71
  } from "../core/telemetry.js";
60
72
  import {
61
73
  isSSOEnabled,
@@ -128,11 +140,153 @@ function refreshContext(root) {
128
140
  }
129
141
  }
130
142
 
143
+ // --- Rule packs (speclock init --from <framework>) ---
144
+
145
+ const RULE_PACKS = {
146
+ nextjs: {
147
+ name: "nextjs",
148
+ displayName: "Next.js",
149
+ description: "Next.js (App Router, Server Components, TypeScript)",
150
+ },
151
+ fastapi: {
152
+ name: "fastapi",
153
+ displayName: "FastAPI",
154
+ description: "FastAPI + Python (async, Pydantic, JWT)",
155
+ },
156
+ rails: {
157
+ name: "rails",
158
+ displayName: "Ruby on Rails",
159
+ description: "Ruby on Rails (Strong Params, ActiveRecord)",
160
+ },
161
+ react: {
162
+ name: "react",
163
+ displayName: "React",
164
+ description: "Generic React (hooks, state management)",
165
+ },
166
+ python: {
167
+ name: "python",
168
+ displayName: "Python",
169
+ description: "Generic Python (security, type hints)",
170
+ },
171
+ node: {
172
+ name: "node",
173
+ displayName: "Node.js/Express",
174
+ description: "Node.js/Express (async, security)",
175
+ },
176
+ };
177
+
178
+ /**
179
+ * Locate the rule-packs directory relative to this module.
180
+ * Works for local dev, npm global installs, and npx cache.
181
+ */
182
+ function getRulePacksDir() {
183
+ const here = path.dirname(fileURLToPath(import.meta.url));
184
+ // src/cli/index.js -> src/templates/rule-packs
185
+ return path.resolve(here, "..", "templates", "rule-packs");
186
+ }
187
+
188
+ /**
189
+ * Read a rule pack file. Returns the raw markdown and an estimated rule count
190
+ * (number of list items under any "## Rules" section).
191
+ */
192
+ export function loadRulePack(framework) {
193
+ const pack = RULE_PACKS[framework];
194
+ if (!pack) {
195
+ return {
196
+ ok: false,
197
+ error:
198
+ `Unknown framework "${framework}". ` +
199
+ `Run "speclock init --from list" to see available rule packs.`,
200
+ };
201
+ }
202
+ const dir = getRulePacksDir();
203
+ const filePath = path.join(dir, `${pack.name}.md`);
204
+ if (!fs.existsSync(filePath)) {
205
+ return {
206
+ ok: false,
207
+ error: `Rule pack file missing: ${filePath}`,
208
+ };
209
+ }
210
+ const content = fs.readFileSync(filePath, "utf-8");
211
+ if (!content.trim()) {
212
+ return { ok: false, error: `Rule pack "${framework}" is empty.` };
213
+ }
214
+ // Count rules: lines under any "## Rules" heading that begin with "- " or "* ".
215
+ const lines = content.split("\n");
216
+ let inRules = false;
217
+ let ruleCount = 0;
218
+ for (const line of lines) {
219
+ const trimmed = line.trim();
220
+ if (/^##\s+Rules\s*$/i.test(trimmed)) {
221
+ inRules = true;
222
+ continue;
223
+ }
224
+ if (inRules && /^##\s+/.test(trimmed)) {
225
+ inRules = false;
226
+ continue;
227
+ }
228
+ if (inRules && /^[-*]\s+/.test(trimmed)) ruleCount++;
229
+ }
230
+ return { ok: true, pack, content, ruleCount, filePath };
231
+ }
232
+
233
+ function printRulePackList() {
234
+ console.log("\nAvailable rule packs:");
235
+ const order = ["nextjs", "fastapi", "rails", "react", "python", "node"];
236
+ const pad = Math.max(...order.map((k) => k.length));
237
+ for (const key of order) {
238
+ const p = RULE_PACKS[key];
239
+ console.log(` ${p.name.padEnd(pad)} — ${p.description}`);
240
+ }
241
+ console.log("\nUsage: speclock init --from <framework>");
242
+ console.log("Example: speclock init --from nextjs\n");
243
+ }
244
+
245
+ /**
246
+ * Write (or append) a rule pack to CLAUDE.md in `root`.
247
+ * Returns { ok, displayName, ruleCount, appended, listed, error }.
248
+ */
249
+ export function initFromRulePack(root, framework) {
250
+ if (framework === "list" || framework === "--list" || framework === "help") {
251
+ printRulePackList();
252
+ return { listed: true };
253
+ }
254
+
255
+ const loaded = loadRulePack(framework);
256
+ if (!loaded.ok) return { ok: false, error: loaded.error };
257
+
258
+ const claudePath = path.join(root, "CLAUDE.md");
259
+ let appended = false;
260
+ if (fs.existsSync(claudePath)) {
261
+ appended = true;
262
+ const existing = fs.readFileSync(claudePath, "utf-8");
263
+ const sep =
264
+ existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
265
+ const banner =
266
+ `\n<!-- Appended by speclock init --from ${framework} -->\n\n`;
267
+ fs.writeFileSync(claudePath, existing + sep + banner + loaded.content);
268
+ console.log(
269
+ `⚠ CLAUDE.md already exists — appending ${loaded.pack.displayName} rule pack ` +
270
+ `instead of overwriting.`
271
+ );
272
+ } else {
273
+ fs.writeFileSync(claudePath, loaded.content);
274
+ }
275
+
276
+ return {
277
+ ok: true,
278
+ displayName: loaded.pack.displayName,
279
+ ruleCount: loaded.ruleCount,
280
+ appended,
281
+ path: claudePath,
282
+ };
283
+ }
284
+
131
285
  // --- Help text ---
132
286
 
133
287
  function printHelp() {
134
288
  console.log(`
135
- SpecLock v5.5.4 — Your AI has rules. SpecLock makes them unbreakable.
289
+ SpecLock v5.5.5 — Your AI has rules. SpecLock makes them unbreakable.
136
290
  Developed by Sandeep Roy (github.com/sgroy10)
137
291
 
138
292
  Usage: speclock <command> [options]
@@ -140,6 +294,8 @@ Usage: speclock <command> [options]
140
294
  Commands:
141
295
  setup [--goal <text>] [--template <name>] Full setup: init + SPECLOCK.md + context
142
296
  init Initialize SpecLock in current directory
297
+ init --from <framework> Bootstrap CLAUDE.md from a curated rule pack
298
+ (nextjs, fastapi, rails, react, python, node, list)
143
299
  goal <text> Set or update the project goal
144
300
  lock <text> [--tags a,b] Add a non-negotiable constraint
145
301
  lock remove <id> Remove a lock by ID
@@ -208,7 +364,10 @@ Policy-as-Code (v3.5):
208
364
  policy remove <ruleId> Remove a policy rule
209
365
  policy evaluate <action> Evaluate action against policy rules
210
366
  policy export Export policy as YAML
211
- telemetry [status] Show telemetry status and analytics
367
+ telemetry on Opt in to anonymous usage telemetry
368
+ telemetry off Opt out of telemetry (revokes immediately)
369
+ telemetry status Show opt-in state + last 10 recorded events
370
+ telemetry clear Clear the local telemetry event log
212
371
  sso status Show SSO configuration
213
372
  sso configure --issuer <url> Configure SSO (--client-id, --client-secret)
214
373
 
@@ -286,14 +445,32 @@ function showStatus(root) {
286
445
  // --- Main ---
287
446
 
288
447
  async function main() {
289
- const { cmd, args } = parseArgs(process.argv);
448
+ let { cmd, args } = parseArgs(process.argv);
290
449
  const root = rootDir();
291
450
 
451
+ // Fire-and-forget telemetry: hook process exit so we record every
452
+ // invocation exactly once with its final exit code. Wrapped in try/catch
453
+ // so telemetry failures can never block or break the CLI.
454
+ try {
455
+ process.once("exit", (code) => {
456
+ try {
457
+ recordCommand(cmd || "unknown", typeof code === "number" ? code : 0, {
458
+ projectRoot: root,
459
+ });
460
+ } catch (_) { /* swallow */ }
461
+ });
462
+ } catch (_) { /* swallow */ }
463
+
292
464
  if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
293
465
  printHelp();
294
466
  process.exit(0);
295
467
  }
296
468
 
469
+ // Dispatch loop — allows certain commands to rewrite `cmd`/`args` and
470
+ // `continue dispatch` to re-enter the command switch (used by
471
+ // `audit --pre-commit` to delegate to `audit-semantic`).
472
+ // eslint-disable-next-line no-labels
473
+ dispatch: while (true) {
297
474
  // --- SETUP (new: one-shot full setup) ---
298
475
  if (cmd === "setup") {
299
476
  const flags = parseFlags(args);
@@ -363,6 +540,39 @@ Tip: Run "speclock sync --all" to push constraints to Cursor, Claude, Copilot, W
363
540
 
364
541
  // --- INIT ---
365
542
  if (cmd === "init") {
543
+ const flags = parseFlags(args);
544
+
545
+ // init --from <framework> : bootstrap CLAUDE.md from a curated rule pack
546
+ if (flags.from) {
547
+ const framework = String(flags.from).trim().toLowerCase();
548
+ const result = initFromRulePack(root, framework);
549
+ if (result.listed) {
550
+ // Already printed list, nothing else to do.
551
+ return;
552
+ }
553
+ if (!result.ok) {
554
+ console.error(result.error);
555
+ process.exit(1);
556
+ }
557
+ // Fall through into the rest of the protect flow so the rule pack is
558
+ // actually activated (locks extracted, hook installed, context refreshed).
559
+ const report = protect(root, { strict: false });
560
+ console.log(formatProtectReport(report));
561
+ try {
562
+ setEnforcementMode(root, "advisory");
563
+ } catch (_) { /* ignore */ }
564
+ console.log(
565
+ `✓ Initialized SpecLock with ${result.displayName} rule pack ` +
566
+ `(${result.ruleCount} rules). Edit CLAUDE.md to customize.`
567
+ );
568
+ if (result.appended) {
569
+ console.log(
570
+ " Note: CLAUDE.md already existed — rule pack was APPENDED, not overwritten."
571
+ );
572
+ }
573
+ return;
574
+ }
575
+
366
576
  ensureInit(root);
367
577
  createSpecLockMd(root);
368
578
  injectPackageJsonMarker(root);
@@ -566,6 +776,13 @@ Tip: Run "speclock sync --all" to push constraints to Cursor, Claude, Copilot, W
566
776
 
567
777
  // --- PROTECT (zero-config guardian mode) ---
568
778
  if (cmd === "protect") {
779
+ // First-run opt-in prompt (only on the first 'protect' ever, only on TTY).
780
+ try {
781
+ if (!hasTelemetryDecision()) {
782
+ await ensureTelemetryDecision();
783
+ }
784
+ } catch (_) { /* swallow — telemetry must never block protect */ }
785
+
569
786
  const flags = parseFlags(args);
570
787
  const strict = flags.strict === true || flags.block === true;
571
788
  const opts = {
@@ -837,6 +1054,19 @@ Tip: Run "speclock sync --all" to push constraints to Cursor, Claude, Copilot, W
837
1054
  // --- AUDIT ---
838
1055
  if (cmd === "audit") {
839
1056
  const flags = parseFlags(args);
1057
+ // When invoked from the pre-commit hook (either new or legacy), always
1058
+ // route to the semantic audit path so the commit message + diff content
1059
+ // are actually fed through the semantic conflict engine. We do this by
1060
+ // rewriting cmd/args so the audit-semantic branch below picks it up on
1061
+ // the next iteration of the outer dispatch loop.
1062
+ if (flags["pre-commit"] === true) {
1063
+ cmd = "audit-semantic";
1064
+ args = args.filter((a) => a !== "--pre-commit");
1065
+ // Skip the rest of this audit block; dispatch loop will re-enter.
1066
+ // (See `dispatch:` label wrapping the command switch.)
1067
+ // eslint-disable-next-line no-labels
1068
+ continue dispatch;
1069
+ }
840
1070
  // Warn mode is the default (investor audit: hard-block had too many false positives).
841
1071
  // Users opt in to hard blocking with --strict, SPECLOCK_STRICT=1, or by running
842
1072
  // `speclock enforce hard` (which sets the persistent brain enforcement mode).
@@ -1015,6 +1245,106 @@ Tip: Run "speclock sync --all" to push constraints to Cursor, Claude, Copilot, W
1015
1245
  const flags = parseFlags(args);
1016
1246
  const result = semanticAudit(root);
1017
1247
 
1248
+ // --- Commit-message + diff-content semantic check ---
1249
+ // The diff-level semanticAudit() above only inspects per-file change
1250
+ // summaries. It does NOT see the commit message, and its summaries can
1251
+ // miss short fragments like "delete user data" that collide with locks
1252
+ // such as "NEVER delete user data". Here we explicitly build a combined
1253
+ // "action description" from the commit message + every added line and
1254
+ // run it through enforceConflictCheck — the same engine used by
1255
+ // `speclock check`.
1256
+ const extraViolations = [];
1257
+ try {
1258
+ // 1. Read commit message (.git/COMMIT_EDITMSG is written by git BEFORE
1259
+ // pre-commit hooks run).
1260
+ let commitMsg = "";
1261
+ const editMsgPath = path.join(root, ".git", "COMMIT_EDITMSG");
1262
+ if (fs.existsSync(editMsgPath)) {
1263
+ try {
1264
+ commitMsg = fs.readFileSync(editMsgPath, "utf-8")
1265
+ .split("\n")
1266
+ .filter((l) => !l.trim().startsWith("#"))
1267
+ .join("\n")
1268
+ .trim();
1269
+ } catch { /* ignore */ }
1270
+ }
1271
+
1272
+ // 2. Collect added lines from the staged diff.
1273
+ const diffText = getStagedDiff(root);
1274
+ const fileChanges = diffText ? parseDiff(diffText) : [];
1275
+ const addedSnippets = [];
1276
+ for (const fc of fileChanges) {
1277
+ for (const line of fc.addedLines) {
1278
+ // Strip common comment markers so "// delete user data" becomes
1279
+ // "delete user data" for the semantic engine.
1280
+ const cleaned = line
1281
+ .replace(/^\s*(\/\/|#|\/\*+|\*+\/?|--|<!--|-->|;)\s*/, "")
1282
+ .replace(/\*\/\s*$/, "")
1283
+ .replace(/-->\s*$/, "")
1284
+ .trim();
1285
+ if (cleaned) addedSnippets.push(cleaned);
1286
+ }
1287
+ }
1288
+
1289
+ // 3. Build a combined action description. We check the commit message
1290
+ // as one unit (highest priority), then individual added snippets.
1291
+ const actionsToCheck = [];
1292
+ if (commitMsg) {
1293
+ actionsToCheck.push({ kind: "commit message", text: commitMsg });
1294
+ }
1295
+ // Cap added snippets to avoid blowing up the check loop on huge diffs.
1296
+ for (const snip of addedSnippets.slice(0, 100)) {
1297
+ actionsToCheck.push({ kind: "added code", text: snip });
1298
+ }
1299
+
1300
+ if (actionsToCheck.length > 0) {
1301
+ for (const item of actionsToCheck) {
1302
+ const check = enforceConflictCheck(root, item.text);
1303
+ if (check.hasConflict && check.conflictingLocks.length > 0) {
1304
+ for (const lock of check.conflictingLocks) {
1305
+ extraViolations.push({
1306
+ file: item.kind === "commit message" ? "(commit message)" : "(staged diff)",
1307
+ lockId: lock.id,
1308
+ lockText: lock.text,
1309
+ confidence: lock.confidence,
1310
+ level: lock.level,
1311
+ reason: `${item.kind}: "${item.text.substring(0, 80)}" — ${(lock.reasons || []).join("; ")}`,
1312
+ source: "commit-msg-semantic",
1313
+ });
1314
+ }
1315
+ }
1316
+ }
1317
+ }
1318
+ } catch (err) {
1319
+ // Never fail the hook on internal errors — just note it.
1320
+ console.log(`(speclock: commit-message semantic check skipped: ${err.message})`);
1321
+ }
1322
+
1323
+ // Merge + dedupe by lockId+source-ish key, keeping highest confidence.
1324
+ if (extraViolations.length > 0) {
1325
+ const merged = [...result.violations, ...extraViolations];
1326
+ const bestByKey = new Map();
1327
+ for (const v of merged) {
1328
+ const key = `${v.file}::${v.lockId || v.lockText}`;
1329
+ const existing = bestByKey.get(key);
1330
+ if (!existing || (v.confidence || 0) > (existing.confidence || 0)) {
1331
+ bestByKey.set(key, v);
1332
+ }
1333
+ }
1334
+ result.violations = [...bestByKey.values()].sort(
1335
+ (a, b) => (b.confidence || 0) - (a.confidence || 0)
1336
+ );
1337
+ // Recompute blocked state against the configured threshold.
1338
+ const threshold = result.threshold || 70;
1339
+ if (result.mode === "hard") {
1340
+ result.blocked = result.violations.some((v) => (v.confidence || 0) >= threshold);
1341
+ }
1342
+ result.passed = result.violations.length === 0;
1343
+ result.message = result.blocked
1344
+ ? `BLOCKED: ${result.violations.length} violation(s) detected. Hard enforcement active — commit rejected.`
1345
+ : `WARNING: ${result.violations.length} violation(s) detected. Review before proceeding.`;
1346
+ }
1347
+
1018
1348
  // Warn mode default: only exit 1 if --strict, SPECLOCK_STRICT=1, or brain is in "hard" mode
1019
1349
  // (result.blocked already reflects "hard" mode from brain config).
1020
1350
  const strict =
@@ -1233,30 +1563,102 @@ Tip: Run "speclock sync --all" to push constraints to Cursor, Claude, Copilot, W
1233
1563
  process.exit(1);
1234
1564
  }
1235
1565
 
1236
- // --- TELEMETRY (v3.5) ---
1566
+ // --- TELEMETRY (opt-in, v5.5) ---
1237
1567
  if (cmd === "telemetry") {
1238
1568
  const sub = args[0];
1239
- if (sub === "status" || !sub) {
1240
- const enabled = isTelemetryEnabled();
1241
- console.log(`\nTelemetry: ${enabled ? "ENABLED" : "DISABLED"}`);
1242
- if (!enabled) {
1243
- console.log("Set SPECLOCK_TELEMETRY=true to enable anonymous usage analytics.");
1244
- return;
1569
+
1570
+ if (sub === "on" || sub === "enable") {
1571
+ try {
1572
+ enableTelemetry();
1573
+ console.log("Telemetry: ENABLED.");
1574
+ console.log("We collect anonymous usage data only. See: speclock telemetry status");
1575
+ console.log("To opt out at any time: speclock telemetry off");
1576
+ } catch (_) {
1577
+ console.error("Failed to enable telemetry (ignored).");
1578
+ }
1579
+ return;
1580
+ }
1581
+
1582
+ if (sub === "off" || sub === "disable") {
1583
+ try {
1584
+ disableTelemetry();
1585
+ console.log("Telemetry: DISABLED. No further events will be recorded or sent.");
1586
+ } catch (_) {
1587
+ console.error("Failed to disable telemetry (ignored).");
1588
+ }
1589
+ return;
1590
+ }
1591
+
1592
+ if (sub === "clear") {
1593
+ try {
1594
+ const r = clearTelemetryLog();
1595
+ console.log(r.cleared ? "Telemetry log cleared." : "No telemetry log to clear.");
1596
+ } catch (_) {
1597
+ console.error("Failed to clear telemetry log (ignored).");
1245
1598
  }
1246
- const summary = getTelemetrySummary(root);
1247
- console.log(`Total calls: ${summary.totalCalls}`);
1248
- console.log(`Avg response: ${summary.avgResponseMs}ms`);
1249
- console.log(`Sessions: ${summary.sessions.total}`);
1250
- console.log(`Conflicts: ${summary.conflicts.total} (blocked: ${summary.conflicts.blocked})`);
1251
- if (summary.topTools.length > 0) {
1252
- console.log(`\nTop tools:`);
1253
- for (const t of summary.topTools.slice(0, 5)) {
1254
- console.log(` ${t.name}: ${t.count} calls`);
1599
+ return;
1600
+ }
1601
+
1602
+ if (sub === "status" || !sub) {
1603
+ try {
1604
+ const st = getOptInTelemetryStatus({ eventLimit: 10 });
1605
+ console.log(`\nSpecLock Telemetry (opt-in)`);
1606
+ console.log("=".repeat(50));
1607
+ console.log(`State: ${st.enabled ? "ENABLED" : "DISABLED"}`);
1608
+ console.log(`Decided: ${st.decided ? "yes" : "no (will prompt on next 'speclock protect')"}`);
1609
+ if (st.decidedAt) console.log(`Decided at: ${st.decidedAt}`);
1610
+ if (st.installedAt) console.log(`First run: ${st.installedAt}`);
1611
+ console.log(`Install id: ${st.installId}`);
1612
+ console.log(`Endpoint: ${st.endpoint || "(disabled)"}`);
1613
+ console.log(`Config file: ${st.configPath}`);
1614
+ console.log(`Events file: ${st.eventsPath}`);
1615
+ if (st.envOverride) console.log(`Env override: SPECLOCK_TELEMETRY=${st.envOverride}`);
1616
+ console.log(`Total events: ${st.eventCount}`);
1617
+ console.log("");
1618
+ console.log("What we collect (anonymous, no PII):");
1619
+ console.log(" installId, version, os, nodeVersion, command, exitCode,");
1620
+ console.log(" enforcementMode, lockCount, ruleFilesFound,");
1621
+ console.log(" mcpClientsConfigured, daysSinceInstall, timestamp");
1622
+ console.log("");
1623
+ console.log("What we NEVER collect:");
1624
+ console.log(" file contents, commit messages, lock content, user names,");
1625
+ console.log(" file paths, IP addresses, project names");
1626
+ console.log("");
1627
+ if (st.sampleEvent) {
1628
+ console.log("Sample event payload:");
1629
+ console.log(JSON.stringify(st.sampleEvent, null, 2));
1630
+ console.log("");
1631
+ }
1632
+ if (st.recentEvents.length > 0) {
1633
+ console.log(`Last ${st.recentEvents.length} event(s):`);
1634
+ for (const e of st.recentEvents) {
1635
+ console.log(` ${e.timestamp} ${e.command} exit=${e.exitCode} mode=${e.enforcementMode} locks=${e.lockCount}`);
1636
+ }
1637
+ console.log("");
1638
+ } else {
1639
+ console.log("No events recorded yet.");
1640
+ console.log("");
1255
1641
  }
1642
+
1643
+ // Also surface the legacy per-project analytics if that layer is enabled.
1644
+ if (isTelemetryEnabled()) {
1645
+ const legacy = getTelemetrySummary(root);
1646
+ if (legacy.enabled) {
1647
+ console.log("Per-project analytics (SPECLOCK_TELEMETRY):");
1648
+ console.log(` Total MCP tool calls: ${legacy.totalCalls}`);
1649
+ console.log(` Avg response: ${legacy.avgResponseMs}ms`);
1650
+ console.log(` Sessions: ${legacy.sessions.total}`);
1651
+ console.log(` Conflicts: ${legacy.conflicts.total} (blocked: ${legacy.conflicts.blocked})`);
1652
+ console.log("");
1653
+ }
1654
+ }
1655
+ } catch (_) {
1656
+ console.error("Failed to read telemetry status (ignored).");
1256
1657
  }
1257
1658
  return;
1258
1659
  }
1259
- console.error("Usage: speclock telemetry [status]");
1660
+
1661
+ console.error("Usage: speclock telemetry <on|off|status|clear>");
1260
1662
  process.exit(1);
1261
1663
  }
1262
1664
 
@@ -1477,16 +1879,31 @@ Tip: Run "speclock sync --all" to push constraints to Cursor, Claude, Copilot, W
1477
1879
  lines.push("Installation");
1478
1880
  let pkgVersion = "unknown";
1479
1881
  try {
1480
- // Find our own package.json walk up from this module
1481
- const selfPkgPath = path.join(root, "node_modules", "speclock", "package.json");
1482
- if (fs.existsSync(selfPkgPath)) {
1483
- pkgVersion = JSON.parse(fs.readFileSync(selfPkgPath, "utf-8")).version;
1484
- } else {
1485
- // Maybe running from the repo itself
1486
- const localPkg = path.join(root, "package.json");
1487
- if (fs.existsSync(localPkg)) {
1488
- const p = JSON.parse(fs.readFileSync(localPkg, "utf-8"));
1489
- if (p.name === "speclock") pkgVersion = p.version;
1882
+ // Walk up from this module's directory to find speclock's package.json
1883
+ // (works for: local install, npm global, npx cache)
1884
+ let dir = path.dirname(fileURLToPath(import.meta.url));
1885
+ for (let i = 0; i < 5; i++) {
1886
+ const candidate = path.join(dir, "package.json");
1887
+ if (fs.existsSync(candidate)) {
1888
+ const p = JSON.parse(fs.readFileSync(candidate, "utf-8"));
1889
+ if (p.name === "speclock") {
1890
+ pkgVersion = p.version;
1891
+ break;
1892
+ }
1893
+ }
1894
+ dir = path.dirname(dir);
1895
+ }
1896
+ // Fallbacks
1897
+ if (pkgVersion === "unknown") {
1898
+ const selfPkgPath = path.join(root, "node_modules", "speclock", "package.json");
1899
+ if (fs.existsSync(selfPkgPath)) {
1900
+ pkgVersion = JSON.parse(fs.readFileSync(selfPkgPath, "utf-8")).version;
1901
+ } else {
1902
+ const localPkg = path.join(root, "package.json");
1903
+ if (fs.existsSync(localPkg)) {
1904
+ const p = JSON.parse(fs.readFileSync(localPkg, "utf-8"));
1905
+ if (p.name === "speclock") pkgVersion = p.version;
1906
+ }
1490
1907
  }
1491
1908
  }
1492
1909
  lines.push(` ✓ SpecLock v${pkgVersion} installed`);
@@ -1790,12 +2207,40 @@ Tip: Run "speclock sync --all" to push constraints to Cursor, Claude, Copilot, W
1790
2207
  return;
1791
2208
  }
1792
2209
 
2210
+ // End of dispatch loop. If no handler returned/exited, fall through.
2211
+ break;
2212
+ } // end dispatch: while
2213
+
1793
2214
  console.error(`Unknown command: ${cmd}`);
1794
2215
  console.error("Run 'speclock --help' for usage.");
1795
2216
  process.exit(1);
1796
2217
  }
1797
2218
 
1798
- main().catch((err) => {
1799
- console.error("SpecLock error:", err.message);
1800
- process.exit(1);
1801
- });
2219
+ // Only run the CLI when this file is invoked as the entry point — either
2220
+ // directly (`node src/cli/index.js`) or through the bin wrapper
2221
+ // (`bin/speclock.js` does `import "../src/cli/index.js"`). Skip autorun when
2222
+ // this module is imported by tests or other tooling that only needs the
2223
+ // exported helpers (`loadRulePack`, `initFromRulePack`).
2224
+ const shouldAutoRun = (() => {
2225
+ if (process.env.SPECLOCK_CLI_NO_AUTORUN === "1") return false;
2226
+ try {
2227
+ const thisFile = fileURLToPath(import.meta.url);
2228
+ const entryFile = process.argv[1] ? path.resolve(process.argv[1]) : "";
2229
+ if (!entryFile) return true;
2230
+ if (thisFile === entryFile) return true;
2231
+ // bin wrapper: bin/speclock.js (or any file under a /bin/ directory
2232
+ // whose basename starts with "speclock").
2233
+ const base = path.basename(entryFile).toLowerCase();
2234
+ if (base.startsWith("speclock")) return true;
2235
+ return false;
2236
+ } catch (_) {
2237
+ return true;
2238
+ }
2239
+ })();
2240
+
2241
+ if (shouldAutoRun) {
2242
+ main().catch((err) => {
2243
+ console.error("SpecLock error:", err.message);
2244
+ process.exit(1);
2245
+ });
2246
+ }
@@ -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.4";
12
+ const VERSION = "5.5.5";
13
13
 
14
14
  // PHI-related keywords for HIPAA filtering
15
15
  const PHI_KEYWORDS = [
package/src/core/hooks.js CHANGED
@@ -8,11 +8,14 @@ const HOOK_MARKER = "# SPECLOCK-HOOK";
8
8
 
9
9
  const HOOK_SCRIPT = `#!/bin/sh
10
10
  ${HOOK_MARKER} — Do not remove this line
11
- # SpecLock pre-commit hook: checks staged files against active locks
11
+ # SpecLock pre-commit hook: runs semantic audit of staged diff + commit message
12
+ # against active locks. Unlike the legacy 'audit' subcommand, this one feeds
13
+ # the actual diff content AND the commit message through the semantic conflict
14
+ # engine — the same one used by 'speclock check'.
12
15
  # Install: npx speclock hook install
13
16
  # Remove: npx speclock hook remove
14
17
 
15
- npx speclock audit
18
+ npx speclock audit-semantic --pre-commit
16
19
  exit $?
17
20
  `;
18
21