speclock 5.5.3 → 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/README.md +264 -336
- package/package.json +217 -1
- package/src/cli/index.js +858 -39
- package/src/core/auth.js +8 -0
- package/src/core/compliance.js +1 -1
- package/src/core/enforcer.js +7 -1
- package/src/core/guardian.js +78 -5
- package/src/core/hooks.js +5 -2
- package/src/core/lock-author.js +8 -0
- package/src/core/mcp-install.js +484 -0
- package/src/core/telemetry.js +685 -114
- package/src/dashboard/index.html +2 -2
- package/src/mcp/http-server.js +2 -2
- package/src/mcp/server.js +1 -1
- package/src/templates/rule-packs/fastapi.md +22 -0
- package/src/templates/rule-packs/nextjs.md +22 -0
- package/src/templates/rule-packs/node.md +22 -0
- package/src/templates/rule-packs/python.md +22 -0
- package/src/templates/rule-packs/rails.md +22 -0
- package/src/templates/rule-packs/react.md +22 -0
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,
|
|
@@ -67,7 +79,16 @@ import { getReplay, listSessions, formatReplay } from "../core/replay.js";
|
|
|
67
79
|
import { computeDriftScore, formatDriftScore } from "../core/drift-score.js";
|
|
68
80
|
import { computeCoverage, formatCoverage } from "../core/coverage.js";
|
|
69
81
|
import { analyzeLockStrength, formatStrength } from "../core/strengthen.js";
|
|
70
|
-
import { protect, formatProtectReport } from "../core/guardian.js";
|
|
82
|
+
import { protect, formatProtectReport, discoverRuleFiles, extractConstraints, RULE_FILES } from "../core/guardian.js";
|
|
83
|
+
import {
|
|
84
|
+
installForClient,
|
|
85
|
+
uninstallForClient,
|
|
86
|
+
installAll,
|
|
87
|
+
uninstallAll,
|
|
88
|
+
formatResult,
|
|
89
|
+
nextStepsFor,
|
|
90
|
+
SUPPORTED_CLIENTS,
|
|
91
|
+
} from "../core/mcp-install.js";
|
|
71
92
|
|
|
72
93
|
// --- Argument parsing ---
|
|
73
94
|
|
|
@@ -119,11 +140,153 @@ function refreshContext(root) {
|
|
|
119
140
|
}
|
|
120
141
|
}
|
|
121
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
|
+
|
|
122
285
|
// --- Help text ---
|
|
123
286
|
|
|
124
287
|
function printHelp() {
|
|
125
288
|
console.log(`
|
|
126
|
-
SpecLock v5.5.
|
|
289
|
+
SpecLock v5.5.5 — Your AI has rules. SpecLock makes them unbreakable.
|
|
127
290
|
Developed by Sandeep Roy (github.com/sgroy10)
|
|
128
291
|
|
|
129
292
|
Usage: speclock <command> [options]
|
|
@@ -131,10 +294,17 @@ Usage: speclock <command> [options]
|
|
|
131
294
|
Commands:
|
|
132
295
|
setup [--goal <text>] [--template <name>] Full setup: init + SPECLOCK.md + context
|
|
133
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)
|
|
134
299
|
goal <text> Set or update the project goal
|
|
135
300
|
lock <text> [--tags a,b] Add a non-negotiable constraint
|
|
136
301
|
lock remove <id> Remove a lock by ID
|
|
137
|
-
protect
|
|
302
|
+
protect [--strict] Zero-config: read rule files, extract locks, install hook
|
|
303
|
+
(default: warn mode — violations print but DON'T block commits.
|
|
304
|
+
Add --strict for hard blocks.)
|
|
305
|
+
mcp install <client> Auto-install SpecLock MCP server into an AI client
|
|
306
|
+
(claude-code, cursor, windsurf, cline, codex, all)
|
|
307
|
+
mcp uninstall <client> Remove SpecLock MCP server from an AI client
|
|
138
308
|
guard <file> [--lock "text"] Inject lock warning into a file
|
|
139
309
|
unguard <file> Remove lock warning from a file
|
|
140
310
|
decide <text> [--tags a,b] Record a decision
|
|
@@ -146,8 +316,10 @@ Commands:
|
|
|
146
316
|
report Show violation report + stats
|
|
147
317
|
hook install Install git pre-commit hook
|
|
148
318
|
hook remove Remove git pre-commit hook
|
|
149
|
-
audit
|
|
150
|
-
|
|
319
|
+
audit [--strict] Audit staged files against locks (warn mode default;
|
|
320
|
+
--strict or SPECLOCK_STRICT=1 exits 1 on violation)
|
|
321
|
+
audit-semantic [--strict] Semantic audit: analyze code changes vs locks
|
|
322
|
+
(warn mode default; use --strict for hard blocks)
|
|
151
323
|
audit-verify Verify HMAC audit chain integrity
|
|
152
324
|
enforce <advisory|hard> Set enforcement mode (advisory=warn, hard=block)
|
|
153
325
|
override <lockId> <reason> Override a lock with justification
|
|
@@ -168,6 +340,7 @@ Commands:
|
|
|
168
340
|
watch Start file watcher (live dashboard)
|
|
169
341
|
serve [--project <path>] Start MCP stdio server
|
|
170
342
|
status Show project brain summary
|
|
343
|
+
doctor Diagnostic health check (install, git, rules, MCP)
|
|
171
344
|
|
|
172
345
|
Options:
|
|
173
346
|
--tags <a,b,c> Comma-separated tags
|
|
@@ -191,7 +364,10 @@ Policy-as-Code (v3.5):
|
|
|
191
364
|
policy remove <ruleId> Remove a policy rule
|
|
192
365
|
policy evaluate <action> Evaluate action against policy rules
|
|
193
366
|
policy export Export policy as YAML
|
|
194
|
-
telemetry
|
|
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
|
|
195
371
|
sso status Show SSO configuration
|
|
196
372
|
sso configure --issuer <url> Configure SSO (--client-id, --client-secret)
|
|
197
373
|
|
|
@@ -269,14 +445,32 @@ function showStatus(root) {
|
|
|
269
445
|
// --- Main ---
|
|
270
446
|
|
|
271
447
|
async function main() {
|
|
272
|
-
|
|
448
|
+
let { cmd, args } = parseArgs(process.argv);
|
|
273
449
|
const root = rootDir();
|
|
274
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
|
+
|
|
275
464
|
if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
|
|
276
465
|
printHelp();
|
|
277
466
|
process.exit(0);
|
|
278
467
|
}
|
|
279
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) {
|
|
280
474
|
// --- SETUP (new: one-shot full setup) ---
|
|
281
475
|
if (cmd === "setup") {
|
|
282
476
|
const flags = parseFlags(args);
|
|
@@ -346,6 +540,39 @@ Tip: Run "speclock sync --all" to push constraints to Cursor, Claude, Copilot, W
|
|
|
346
540
|
|
|
347
541
|
// --- INIT ---
|
|
348
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
|
+
|
|
349
576
|
ensureInit(root);
|
|
350
577
|
createSpecLockMd(root);
|
|
351
578
|
injectPackageJsonMarker(root);
|
|
@@ -549,13 +776,38 @@ Tip: Run "speclock sync --all" to push constraints to Cursor, Claude, Copilot, W
|
|
|
549
776
|
|
|
550
777
|
// --- PROTECT (zero-config guardian mode) ---
|
|
551
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
|
+
|
|
552
786
|
const flags = parseFlags(args);
|
|
787
|
+
const strict = flags.strict === true || flags.block === true;
|
|
553
788
|
const opts = {
|
|
554
789
|
skipHook: flags["no-hook"] === true,
|
|
555
790
|
skipSync: flags["no-sync"] === true,
|
|
791
|
+
strict,
|
|
556
792
|
};
|
|
557
793
|
const report = protect(root, opts);
|
|
558
794
|
console.log(formatProtectReport(report));
|
|
795
|
+
|
|
796
|
+
// Set persistent enforcement mode on the brain so the hook honours it.
|
|
797
|
+
// Default is "advisory" (warn). Users opt in to hard blocks with --strict.
|
|
798
|
+
try {
|
|
799
|
+
setEnforcementMode(root, strict ? "hard" : "advisory");
|
|
800
|
+
} catch (_) { /* ignore — brain may not exist yet */ }
|
|
801
|
+
|
|
802
|
+
if (strict) {
|
|
803
|
+
console.log(" Hard enforcement active. Every commit that violates a lock will be BLOCKED.");
|
|
804
|
+
console.log(" To relax: speclock protect (without --strict)");
|
|
805
|
+
} else {
|
|
806
|
+
console.log(" Warning mode active. To enforce hard blocks, run: speclock protect --strict");
|
|
807
|
+
console.log(" Violations will be printed at commit time but commits will NOT be blocked.");
|
|
808
|
+
}
|
|
809
|
+
console.log("");
|
|
810
|
+
|
|
559
811
|
if (report.errors.length > 0 && report.discovered.length === 0) {
|
|
560
812
|
process.exit(1);
|
|
561
813
|
}
|
|
@@ -612,6 +864,101 @@ Tip: Run "speclock sync --all" to push constraints to Cursor, Claude, Copilot, W
|
|
|
612
864
|
return;
|
|
613
865
|
}
|
|
614
866
|
|
|
867
|
+
// --- MCP INSTALL / UNINSTALL ---
|
|
868
|
+
// One-command autoinstaller: wires SpecLock into Claude Code, Cursor,
|
|
869
|
+
// Windsurf, Cline, Codex (or all of them) without any JSON hand-editing.
|
|
870
|
+
if (cmd === "mcp") {
|
|
871
|
+
const sub = args[0];
|
|
872
|
+
const client = args[1];
|
|
873
|
+
const flags = parseFlags(args.slice(2));
|
|
874
|
+
|
|
875
|
+
const supportedLabel = SUPPORTED_CLIENTS.join(", ");
|
|
876
|
+
|
|
877
|
+
if (!sub || (sub !== "install" && sub !== "uninstall")) {
|
|
878
|
+
console.error("Usage:");
|
|
879
|
+
console.error(` speclock mcp install <client> (${supportedLabel})`);
|
|
880
|
+
console.error(` speclock mcp uninstall <client>`);
|
|
881
|
+
console.error("");
|
|
882
|
+
console.error("Flags:");
|
|
883
|
+
console.error(" --no-project Skip project-scoped config (.mcp.json, .cursor/mcp.json)");
|
|
884
|
+
process.exit(1);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
if (!client) {
|
|
888
|
+
console.error(`Error: <client> is required.`);
|
|
889
|
+
console.error(`Supported: ${supportedLabel}`);
|
|
890
|
+
process.exit(1);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
if (!SUPPORTED_CLIENTS.includes(client)) {
|
|
894
|
+
console.error(`Unknown client "${client}".`);
|
|
895
|
+
console.error(`Supported: ${supportedLabel}`);
|
|
896
|
+
process.exit(1);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
const options = {
|
|
900
|
+
includeProject: flags["no-project"] !== true,
|
|
901
|
+
};
|
|
902
|
+
|
|
903
|
+
const isInstall = sub === "install";
|
|
904
|
+
const header = isInstall
|
|
905
|
+
? "\nSpecLock MCP — Autoinstaller"
|
|
906
|
+
: "\nSpecLock MCP — Uninstaller";
|
|
907
|
+
console.log(header);
|
|
908
|
+
console.log("=".repeat(50));
|
|
909
|
+
|
|
910
|
+
let results;
|
|
911
|
+
if (client === "all") {
|
|
912
|
+
results = isInstall
|
|
913
|
+
? installAll(root, options)
|
|
914
|
+
: uninstallAll(root, options);
|
|
915
|
+
} else {
|
|
916
|
+
results = [
|
|
917
|
+
isInstall
|
|
918
|
+
? installForClient(client, root, options)
|
|
919
|
+
: uninstallForClient(client, root, options),
|
|
920
|
+
];
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
let anySuccess = false;
|
|
924
|
+
let anyError = false;
|
|
925
|
+
|
|
926
|
+
for (const r of results) {
|
|
927
|
+
console.log(`\n ${r.client}:`);
|
|
928
|
+
console.log(formatResult(r, sub));
|
|
929
|
+
if (r.errors.length > 0) anyError = true;
|
|
930
|
+
if (
|
|
931
|
+
r.writes.some(
|
|
932
|
+
(w) => w.status === "installed" || w.status === "removed"
|
|
933
|
+
)
|
|
934
|
+
) {
|
|
935
|
+
anySuccess = true;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
console.log("");
|
|
940
|
+
if (isInstall && anySuccess) {
|
|
941
|
+
console.log(" Next steps:");
|
|
942
|
+
if (client === "all") {
|
|
943
|
+
console.log(" Restart any AI clients that were updated.");
|
|
944
|
+
} else {
|
|
945
|
+
console.log(` ${nextStepsFor(client)}`);
|
|
946
|
+
}
|
|
947
|
+
console.log("");
|
|
948
|
+
console.log(" Verify: speclock status");
|
|
949
|
+
} else if (!isInstall && anySuccess) {
|
|
950
|
+
console.log(" SpecLock MCP server removed. Restart your AI client to apply.");
|
|
951
|
+
} else if (!anySuccess && !anyError) {
|
|
952
|
+
console.log(
|
|
953
|
+
isInstall
|
|
954
|
+
? " SpecLock was already installed everywhere. Nothing to do."
|
|
955
|
+
: " SpecLock was not installed anywhere. Nothing to do."
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
process.exit(anyError ? 1 : 0);
|
|
960
|
+
}
|
|
961
|
+
|
|
615
962
|
// --- TEMPLATE ---
|
|
616
963
|
if (cmd === "template") {
|
|
617
964
|
const sub = args[0];
|
|
@@ -706,23 +1053,60 @@ Tip: Run "speclock sync --all" to push constraints to Cursor, Claude, Copilot, W
|
|
|
706
1053
|
|
|
707
1054
|
// --- AUDIT ---
|
|
708
1055
|
if (cmd === "audit") {
|
|
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
|
+
}
|
|
1070
|
+
// Warn mode is the default (investor audit: hard-block had too many false positives).
|
|
1071
|
+
// Users opt in to hard blocking with --strict, SPECLOCK_STRICT=1, or by running
|
|
1072
|
+
// `speclock enforce hard` (which sets the persistent brain enforcement mode).
|
|
1073
|
+
const brain = readBrain(root);
|
|
1074
|
+
const brainMode = brain ? (getEnforcementConfig(brain).mode || "advisory") : "advisory";
|
|
1075
|
+
const strict =
|
|
1076
|
+
flags.strict === true ||
|
|
1077
|
+
flags.block === true ||
|
|
1078
|
+
process.env.SPECLOCK_STRICT === "1" ||
|
|
1079
|
+
process.env.SPECLOCK_STRICT === "true" ||
|
|
1080
|
+
brainMode === "hard";
|
|
1081
|
+
|
|
709
1082
|
const result = auditStagedFiles(root);
|
|
710
1083
|
if (result.passed) {
|
|
711
1084
|
console.log(result.message);
|
|
712
1085
|
process.exit(0);
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
}
|
|
722
|
-
console.log(
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// Violations found — print them for both warn and strict modes.
|
|
1089
|
+
const header = strict ? "SPECLOCK AUDIT FAILED" : "SPECLOCK WARNINGS";
|
|
1090
|
+
console.log(`\n${header}`);
|
|
1091
|
+
console.log("=".repeat(50));
|
|
1092
|
+
for (const v of result.violations) {
|
|
1093
|
+
console.log(` [${v.severity}] ${v.file}`);
|
|
1094
|
+
console.log(` Lock: ${v.lockText}`);
|
|
1095
|
+
console.log(` Reason: ${v.reason}`);
|
|
1096
|
+
console.log("");
|
|
1097
|
+
}
|
|
1098
|
+
console.log(result.message);
|
|
1099
|
+
|
|
1100
|
+
if (strict) {
|
|
723
1101
|
console.log("Commit blocked. Unlock files or unstage them to proceed.");
|
|
724
1102
|
process.exit(1);
|
|
725
1103
|
}
|
|
1104
|
+
|
|
1105
|
+
console.log("Warning mode active — commit allowed. To enforce hard blocks, run:");
|
|
1106
|
+
console.log(" speclock audit --strict");
|
|
1107
|
+
console.log(" SPECLOCK_STRICT=1 git commit ...");
|
|
1108
|
+
console.log(" speclock enforce hard (persistent, project-wide)");
|
|
1109
|
+
process.exit(0);
|
|
726
1110
|
}
|
|
727
1111
|
|
|
728
1112
|
// --- AUDIT-VERIFY (v2.1 enterprise) ---
|
|
@@ -858,7 +1242,118 @@ Tip: Run "speclock sync --all" to push constraints to Cursor, Claude, Copilot, W
|
|
|
858
1242
|
|
|
859
1243
|
// --- AUDIT-SEMANTIC (v2.5) ---
|
|
860
1244
|
if (cmd === "audit-semantic") {
|
|
1245
|
+
const flags = parseFlags(args);
|
|
861
1246
|
const result = semanticAudit(root);
|
|
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
|
+
|
|
1348
|
+
// Warn mode default: only exit 1 if --strict, SPECLOCK_STRICT=1, or brain is in "hard" mode
|
|
1349
|
+
// (result.blocked already reflects "hard" mode from brain config).
|
|
1350
|
+
const strict =
|
|
1351
|
+
flags.strict === true ||
|
|
1352
|
+
flags.block === true ||
|
|
1353
|
+
process.env.SPECLOCK_STRICT === "1" ||
|
|
1354
|
+
process.env.SPECLOCK_STRICT === "true" ||
|
|
1355
|
+
result.blocked;
|
|
1356
|
+
|
|
862
1357
|
console.log(`\nSemantic Pre-Commit Audit`);
|
|
863
1358
|
console.log("=".repeat(50));
|
|
864
1359
|
console.log(`Mode: ${result.mode} | Threshold: ${result.threshold}%`);
|
|
@@ -877,7 +1372,15 @@ Tip: Run "speclock sync --all" to push constraints to Cursor, Claude, Copilot, W
|
|
|
877
1372
|
}
|
|
878
1373
|
}
|
|
879
1374
|
console.log(`\n${result.message}`);
|
|
880
|
-
|
|
1375
|
+
|
|
1376
|
+
if (result.violations.length > 0 && !strict) {
|
|
1377
|
+
console.log("\nWarning mode active — commit allowed. To enforce hard blocks, run:");
|
|
1378
|
+
console.log(" speclock audit-semantic --strict");
|
|
1379
|
+
console.log(" SPECLOCK_STRICT=1 git commit ...");
|
|
1380
|
+
console.log(" speclock enforce hard (persistent, project-wide)");
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
process.exit(strict && result.violations.length > 0 ? 1 : 0);
|
|
881
1384
|
}
|
|
882
1385
|
|
|
883
1386
|
// --- AUTH (v3.0) ---
|
|
@@ -1060,30 +1563,102 @@ Tip: Run "speclock sync --all" to push constraints to Cursor, Claude, Copilot, W
|
|
|
1060
1563
|
process.exit(1);
|
|
1061
1564
|
}
|
|
1062
1565
|
|
|
1063
|
-
// --- TELEMETRY (
|
|
1566
|
+
// --- TELEMETRY (opt-in, v5.5) ---
|
|
1064
1567
|
if (cmd === "telemetry") {
|
|
1065
1568
|
const sub = args[0];
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
console.log("
|
|
1071
|
-
|
|
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).");
|
|
1072
1578
|
}
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
console.log(
|
|
1080
|
-
|
|
1081
|
-
|
|
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).");
|
|
1598
|
+
}
|
|
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("");
|
|
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
|
+
}
|
|
1082
1654
|
}
|
|
1655
|
+
} catch (_) {
|
|
1656
|
+
console.error("Failed to read telemetry status (ignored).");
|
|
1083
1657
|
}
|
|
1084
1658
|
return;
|
|
1085
1659
|
}
|
|
1086
|
-
|
|
1660
|
+
|
|
1661
|
+
console.error("Usage: speclock telemetry <on|off|status|clear>");
|
|
1087
1662
|
process.exit(1);
|
|
1088
1663
|
}
|
|
1089
1664
|
|
|
@@ -1287,6 +1862,222 @@ Tip: Run "speclock sync --all" to push constraints to Cursor, Claude, Copilot, W
|
|
|
1287
1862
|
return;
|
|
1288
1863
|
}
|
|
1289
1864
|
|
|
1865
|
+
// --- DOCTOR: Diagnostic health check ---
|
|
1866
|
+
if (cmd === "doctor") {
|
|
1867
|
+
const fs = await import("fs");
|
|
1868
|
+
const os = await import("os");
|
|
1869
|
+
const lines = [];
|
|
1870
|
+
const fixes = [];
|
|
1871
|
+
let issueCount = 0;
|
|
1872
|
+
|
|
1873
|
+
lines.push("");
|
|
1874
|
+
lines.push("SpecLock Doctor — Health Check");
|
|
1875
|
+
lines.push("================================");
|
|
1876
|
+
lines.push("");
|
|
1877
|
+
|
|
1878
|
+
// --- 1. Installation ---
|
|
1879
|
+
lines.push("Installation");
|
|
1880
|
+
let pkgVersion = "unknown";
|
|
1881
|
+
try {
|
|
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
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
lines.push(` ✓ SpecLock v${pkgVersion} installed`);
|
|
1910
|
+
} catch (e) {
|
|
1911
|
+
lines.push(` ✗ SpecLock version check failed: ${e.message}`);
|
|
1912
|
+
issueCount++;
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
const speclockDir = path.join(root, ".speclock");
|
|
1916
|
+
if (fs.existsSync(speclockDir)) {
|
|
1917
|
+
lines.push(` ✓ .speclock/ directory present`);
|
|
1918
|
+
} else {
|
|
1919
|
+
lines.push(` ✗ .speclock/ directory missing`);
|
|
1920
|
+
fixes.push("Run: speclock setup");
|
|
1921
|
+
issueCount++;
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
const brainPath = path.join(speclockDir, "brain.json");
|
|
1925
|
+
let brain = null;
|
|
1926
|
+
let activeLockCount = 0;
|
|
1927
|
+
if (fs.existsSync(brainPath)) {
|
|
1928
|
+
try {
|
|
1929
|
+
brain = JSON.parse(fs.readFileSync(brainPath, "utf-8"));
|
|
1930
|
+
activeLockCount = (brain.specLock?.items || []).filter((l) => l.active !== false).length;
|
|
1931
|
+
lines.push(` ✓ brain.json valid (${activeLockCount} locks)`);
|
|
1932
|
+
} catch (e) {
|
|
1933
|
+
lines.push(` ✗ brain.json is not valid JSON: ${e.message}`);
|
|
1934
|
+
fixes.push("Delete .speclock/brain.json and run: speclock setup");
|
|
1935
|
+
issueCount++;
|
|
1936
|
+
}
|
|
1937
|
+
} else if (fs.existsSync(speclockDir)) {
|
|
1938
|
+
lines.push(` ✗ brain.json missing`);
|
|
1939
|
+
fixes.push("Run: speclock init");
|
|
1940
|
+
issueCount++;
|
|
1941
|
+
}
|
|
1942
|
+
lines.push("");
|
|
1943
|
+
|
|
1944
|
+
// --- 2. Git Integration ---
|
|
1945
|
+
lines.push("Git Integration");
|
|
1946
|
+
const gitDir = path.join(root, ".git");
|
|
1947
|
+
const isGitRepo = fs.existsSync(gitDir);
|
|
1948
|
+
if (isGitRepo) {
|
|
1949
|
+
lines.push(` ✓ Git repository detected`);
|
|
1950
|
+
} else {
|
|
1951
|
+
lines.push(` ✗ Not a git repository`);
|
|
1952
|
+
fixes.push("Run: git init");
|
|
1953
|
+
issueCount++;
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
if (isGitRepo) {
|
|
1957
|
+
const hookPath = path.join(gitDir, "hooks", "pre-commit");
|
|
1958
|
+
if (fs.existsSync(hookPath)) {
|
|
1959
|
+
const hookContent = fs.readFileSync(hookPath, "utf-8");
|
|
1960
|
+
const hasMarker = hookContent.includes("SPECLOCK-HOOK");
|
|
1961
|
+
const runsSpeclock = /speclock\s+audit/.test(hookContent) || /speclock/.test(hookContent);
|
|
1962
|
+
if (hasMarker && runsSpeclock) {
|
|
1963
|
+
lines.push(` ✓ Pre-commit hook installed`);
|
|
1964
|
+
lines.push(` ✓ Hook runs speclock`);
|
|
1965
|
+
} else if (hasMarker) {
|
|
1966
|
+
lines.push(` ⚠ Pre-commit hook has SpecLock marker but does not run speclock`);
|
|
1967
|
+
fixes.push("Run: speclock hook install");
|
|
1968
|
+
issueCount++;
|
|
1969
|
+
} else {
|
|
1970
|
+
lines.push(` ✗ Pre-commit hook exists but was not installed by SpecLock`);
|
|
1971
|
+
fixes.push("Run: speclock hook install (will append to existing hook)");
|
|
1972
|
+
issueCount++;
|
|
1973
|
+
}
|
|
1974
|
+
} else {
|
|
1975
|
+
lines.push(` ✗ Pre-commit hook not installed`);
|
|
1976
|
+
fixes.push("Run: speclock hook install");
|
|
1977
|
+
issueCount++;
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
// Enforcement mode (if brain exists)
|
|
1982
|
+
if (brain) {
|
|
1983
|
+
const mode = brain.enforcement?.mode || "advisory";
|
|
1984
|
+
const modeLabel = mode === "hard" ? "hard (block)" : "warn (advisory)";
|
|
1985
|
+
lines.push(` ✓ Mode: ${modeLabel}` + (mode !== "hard" ? " (use 'speclock enforce hard' for hard enforcement)" : ""));
|
|
1986
|
+
}
|
|
1987
|
+
lines.push("");
|
|
1988
|
+
|
|
1989
|
+
// --- 3. Rule Files ---
|
|
1990
|
+
lines.push("Rule Files");
|
|
1991
|
+
const discovered = discoverRuleFiles(root);
|
|
1992
|
+
const discoveredMap = new Map(discovered.map((f) => [f.file, f]));
|
|
1993
|
+
let totalRuleFilesFound = 0;
|
|
1994
|
+
for (const entry of RULE_FILES) {
|
|
1995
|
+
const found = discoveredMap.get(entry.file);
|
|
1996
|
+
if (found) {
|
|
1997
|
+
const extracted = extractConstraints(found.content, found.file);
|
|
1998
|
+
lines.push(` ✓ ${entry.file} (${extracted.locks.length} locks extracted)`);
|
|
1999
|
+
totalRuleFilesFound++;
|
|
2000
|
+
} else {
|
|
2001
|
+
lines.push(` ✗ ${entry.file} (not found)`);
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
if (totalRuleFilesFound === 0) {
|
|
2005
|
+
fixes.push("Run: speclock protect (auto-creates a starter CLAUDE.md)");
|
|
2006
|
+
issueCount++;
|
|
2007
|
+
}
|
|
2008
|
+
lines.push("");
|
|
2009
|
+
|
|
2010
|
+
// --- 4. MCP Integration ---
|
|
2011
|
+
lines.push("MCP Integration");
|
|
2012
|
+
const home = os.homedir();
|
|
2013
|
+
|
|
2014
|
+
function checkMcpConfig(label, filePath, fixCmd) {
|
|
2015
|
+
if (fs.existsSync(filePath)) {
|
|
2016
|
+
try {
|
|
2017
|
+
const cfg = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
2018
|
+
const servers = cfg.mcpServers || cfg.servers || {};
|
|
2019
|
+
const hasSpeclock = Object.keys(servers).some((k) => /speclock/i.test(k)) ||
|
|
2020
|
+
JSON.stringify(cfg).toLowerCase().includes("speclock");
|
|
2021
|
+
if (hasSpeclock) {
|
|
2022
|
+
lines.push(` ✓ ${label} (${filePath.replace(home, "~")})`);
|
|
2023
|
+
return true;
|
|
2024
|
+
}
|
|
2025
|
+
lines.push(` ✗ ${label} (config exists at ${filePath.replace(home, "~")}, but SpecLock not configured)`);
|
|
2026
|
+
lines.push(` Fix: ${fixCmd}`);
|
|
2027
|
+
issueCount++;
|
|
2028
|
+
return false;
|
|
2029
|
+
} catch (_) {
|
|
2030
|
+
lines.push(` ✗ ${label} (${filePath.replace(home, "~")}: invalid JSON)`);
|
|
2031
|
+
lines.push(` Fix: ${fixCmd}`);
|
|
2032
|
+
issueCount++;
|
|
2033
|
+
return false;
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
lines.push(` ✗ ${label} (${filePath.replace(home, "~")})`);
|
|
2037
|
+
lines.push(` Fix: ${fixCmd}`);
|
|
2038
|
+
issueCount++;
|
|
2039
|
+
return false;
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
checkMcpConfig(
|
|
2043
|
+
"Claude Code project (.mcp.json)",
|
|
2044
|
+
path.join(root, ".mcp.json"),
|
|
2045
|
+
"speclock mcp install claude-code"
|
|
2046
|
+
);
|
|
2047
|
+
checkMcpConfig(
|
|
2048
|
+
"Claude Code global (~/.claude/mcp.json)",
|
|
2049
|
+
path.join(home, ".claude", "mcp.json"),
|
|
2050
|
+
"speclock mcp install claude-code --global"
|
|
2051
|
+
);
|
|
2052
|
+
checkMcpConfig(
|
|
2053
|
+
"Cursor project (.cursor/mcp.json)",
|
|
2054
|
+
path.join(root, ".cursor", "mcp.json"),
|
|
2055
|
+
"speclock mcp install cursor"
|
|
2056
|
+
);
|
|
2057
|
+
checkMcpConfig(
|
|
2058
|
+
"Cursor global (~/.cursor/mcp.json)",
|
|
2059
|
+
path.join(home, ".cursor", "mcp.json"),
|
|
2060
|
+
"speclock mcp install cursor --global"
|
|
2061
|
+
);
|
|
2062
|
+
checkMcpConfig(
|
|
2063
|
+
"Windsurf (~/.codeium/windsurf/mcp_config.json)",
|
|
2064
|
+
path.join(home, ".codeium", "windsurf", "mcp_config.json"),
|
|
2065
|
+
"speclock mcp install windsurf"
|
|
2066
|
+
);
|
|
2067
|
+
lines.push("");
|
|
2068
|
+
|
|
2069
|
+
// --- 5. Summary ---
|
|
2070
|
+
if (issueCount === 0) {
|
|
2071
|
+
lines.push("VERDICT: ✓ HEALTHY — all checks passed");
|
|
2072
|
+
} else {
|
|
2073
|
+
lines.push(`VERDICT: ⚠ ${issueCount} issue${issueCount === 1 ? "" : "s"} found (see fixes above)`);
|
|
2074
|
+
}
|
|
2075
|
+
lines.push("");
|
|
2076
|
+
|
|
2077
|
+
console.log(lines.join("\n"));
|
|
2078
|
+
process.exit(issueCount === 0 ? 0 : 1);
|
|
2079
|
+
}
|
|
2080
|
+
|
|
1290
2081
|
// --- RELEASE: Automated version bump + publish + deploy ---
|
|
1291
2082
|
if (cmd === "release") {
|
|
1292
2083
|
const bump = args[0]; // "patch", "minor", or "major"
|
|
@@ -1416,12 +2207,40 @@ Tip: Run "speclock sync --all" to push constraints to Cursor, Claude, Copilot, W
|
|
|
1416
2207
|
return;
|
|
1417
2208
|
}
|
|
1418
2209
|
|
|
2210
|
+
// End of dispatch loop. If no handler returned/exited, fall through.
|
|
2211
|
+
break;
|
|
2212
|
+
} // end dispatch: while
|
|
2213
|
+
|
|
1419
2214
|
console.error(`Unknown command: ${cmd}`);
|
|
1420
2215
|
console.error("Run 'speclock --help' for usage.");
|
|
1421
2216
|
process.exit(1);
|
|
1422
2217
|
}
|
|
1423
2218
|
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
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
|
+
}
|