speclock 1.5.1 → 1.7.0
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 +59 -9
- package/package.json +1 -1
- package/src/cli/index.js +174 -17
- package/src/core/engine.js +349 -9
- package/src/core/git.js +6 -0
- package/src/core/hooks.js +87 -0
- package/src/core/storage.js +18 -0
- package/src/core/templates.js +114 -0
- package/src/mcp/http-server.js +42 -3
- package/src/mcp/server.js +132 -2
package/README.md
CHANGED
|
@@ -174,10 +174,10 @@ Result: [HIGH] Conflict detected (confidence: 85%)
|
|
|
174
174
|
| Mode | Platforms | How It Works |
|
|
175
175
|
|------|-----------|--------------|
|
|
176
176
|
| **MCP Remote** | Lovable, bolt.diy, Base44 | Connect via URL — no install needed |
|
|
177
|
-
| **MCP Local** | Claude Code, Cursor, Windsurf, Cline | `npx speclock serve` —
|
|
177
|
+
| **MCP Local** | Claude Code, Cursor, Windsurf, Cline | `npx speclock serve` — 22 tools via MCP |
|
|
178
178
|
| **npm File-Based** | Bolt.new, Aider, Rocket.new | `npx speclock setup` — AI reads SPECLOCK.md + uses CLI |
|
|
179
179
|
|
|
180
|
-
##
|
|
180
|
+
## 22 MCP Tools
|
|
181
181
|
|
|
182
182
|
### Memory Management
|
|
183
183
|
| Tool | Purpose |
|
|
@@ -218,26 +218,76 @@ Result: [HIGH] Conflict detected (confidence: 85%)
|
|
|
218
218
|
| `speclock_detect_drift` | Scan changes for constraint violations |
|
|
219
219
|
| `speclock_health` | Health score + multi-agent timeline |
|
|
220
220
|
|
|
221
|
+
### Templates, Reports & Enforcement (v1.7.0)
|
|
222
|
+
| Tool | Purpose |
|
|
223
|
+
|------|---------|
|
|
224
|
+
| `speclock_apply_template` | Apply pre-built constraint templates (nextjs, react, express, etc.) |
|
|
225
|
+
| `speclock_report` | Violation report — blocked change stats |
|
|
226
|
+
| `speclock_audit` | Audit staged files against active locks |
|
|
227
|
+
|
|
228
|
+
## Auto-Guard: Locks That Actually Work
|
|
229
|
+
|
|
230
|
+
When you add a lock, SpecLock **automatically finds and guards related files**:
|
|
231
|
+
|
|
232
|
+
```
|
|
233
|
+
speclock lock "Never modify auth files"
|
|
234
|
+
→ Auto-guarded 2 related file(s):
|
|
235
|
+
🔒 src/components/Auth.tsx
|
|
236
|
+
🔒 src/contexts/AuthContext.tsx
|
|
237
|
+
|
|
238
|
+
speclock lock "Database must always be Supabase"
|
|
239
|
+
→ Auto-guarded 1 related file(s):
|
|
240
|
+
🔒 src/lib/supabase.ts
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
The guard injects a warning **directly inside the file**. When the AI opens the file to edit it, it sees:
|
|
244
|
+
```
|
|
245
|
+
// ============================================================
|
|
246
|
+
// SPECLOCK-GUARD — DO NOT MODIFY THIS FILE
|
|
247
|
+
// LOCKED: Never modify auth files
|
|
248
|
+
// THIS FILE IS LOCKED. DO NOT EDIT, CHANGE, OR REWRITE ANY PART OF IT.
|
|
249
|
+
// The user must say "unlock" before this file can be changed.
|
|
250
|
+
// A question is NOT permission. Asking about features is NOT permission.
|
|
251
|
+
// ONLY "unlock" or "remove the lock" is permission to edit this file.
|
|
252
|
+
// ============================================================
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Active locks are also embedded in `package.json` — so the AI sees your constraints every time it reads the project config.
|
|
256
|
+
|
|
221
257
|
## CLI Commands
|
|
222
258
|
|
|
223
259
|
```bash
|
|
224
260
|
# Setup
|
|
225
|
-
speclock setup --goal "Build my app"
|
|
261
|
+
speclock setup --goal "Build my app" --template nextjs # One-shot setup + template
|
|
226
262
|
|
|
227
263
|
# Memory
|
|
228
264
|
speclock goal <text> # Set project goal
|
|
229
|
-
speclock lock <text> [--tags a,b] # Add
|
|
265
|
+
speclock lock <text> [--tags a,b] # Add constraint + auto-guard files
|
|
230
266
|
speclock lock remove <id> # Remove a lock
|
|
231
267
|
speclock decide <text> # Record a decision
|
|
232
268
|
speclock note <text> # Add a note
|
|
233
269
|
|
|
270
|
+
# Enforcement
|
|
271
|
+
speclock check <text> # Check for lock conflicts
|
|
272
|
+
speclock guard <file> --lock "text" # Manually guard a specific file
|
|
273
|
+
speclock unguard <file> # Remove guard from file
|
|
274
|
+
|
|
275
|
+
# Templates (v1.7.0)
|
|
276
|
+
speclock template list # List available templates
|
|
277
|
+
speclock template apply <name> # Apply: nextjs, react, express, supabase, stripe, security-hardened
|
|
278
|
+
|
|
279
|
+
# Violation Report (v1.7.0)
|
|
280
|
+
speclock report # Show violation stats + most tested locks
|
|
281
|
+
|
|
282
|
+
# Git Pre-commit Hook (v1.7.0)
|
|
283
|
+
speclock hook install # Install pre-commit hook
|
|
284
|
+
speclock hook remove # Remove pre-commit hook
|
|
285
|
+
speclock audit # Audit staged files against locks
|
|
286
|
+
|
|
234
287
|
# Tracking
|
|
235
288
|
speclock log-change <text> --files x # Log a change
|
|
236
289
|
speclock context # Regenerate context file
|
|
237
290
|
|
|
238
|
-
# Enforcement
|
|
239
|
-
speclock check <text> # Check for lock conflicts
|
|
240
|
-
|
|
241
291
|
# Other
|
|
242
292
|
speclock status # Show brain summary
|
|
243
293
|
speclock serve [--project <path>] # Start MCP server
|
|
@@ -252,7 +302,7 @@ speclock watch # Start file watcher
|
|
|
252
302
|
└──────────────┬──────────────────┬────────────────────┘
|
|
253
303
|
│ │
|
|
254
304
|
MCP Protocol File-Based (npm)
|
|
255
|
-
(
|
|
305
|
+
(22 tool calls) (reads SPECLOCK.md +
|
|
256
306
|
.speclock/context/latest.md,
|
|
257
307
|
runs CLI commands)
|
|
258
308
|
│ │
|
|
@@ -287,4 +337,4 @@ MIT License - see [LICENSE](LICENSE) file.
|
|
|
287
337
|
|
|
288
338
|
---
|
|
289
339
|
|
|
290
|
-
*SpecLock v1.
|
|
340
|
+
*SpecLock v1.7.0 — Because remembering isn't enough. AI needs to respect boundaries.*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "speclock",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "AI constraint engine — MCP server + CLI with active enforcement. Memory + guardrails for AI coding tools. Works with Bolt.new, Claude Code, Cursor, Lovable.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/mcp/server.js",
|
package/src/cli/index.js
CHANGED
|
@@ -14,9 +14,16 @@ import {
|
|
|
14
14
|
guardFile,
|
|
15
15
|
unguardFile,
|
|
16
16
|
injectPackageJsonMarker,
|
|
17
|
+
syncLocksToPackageJson,
|
|
18
|
+
autoGuardRelatedFiles,
|
|
19
|
+
listTemplates,
|
|
20
|
+
applyTemplate,
|
|
21
|
+
generateReport,
|
|
22
|
+
auditStagedFiles,
|
|
17
23
|
} from "../core/engine.js";
|
|
18
24
|
import { generateContext } from "../core/context.js";
|
|
19
25
|
import { readBrain } from "../core/storage.js";
|
|
26
|
+
import { installHook, removeHook } from "../core/hooks.js";
|
|
20
27
|
|
|
21
28
|
// --- Argument parsing ---
|
|
22
29
|
|
|
@@ -72,23 +79,29 @@ function refreshContext(root) {
|
|
|
72
79
|
|
|
73
80
|
function printHelp() {
|
|
74
81
|
console.log(`
|
|
75
|
-
SpecLock v1.
|
|
82
|
+
SpecLock v1.7.0 — AI Constraint Engine
|
|
76
83
|
Developed by Sandeep Roy (github.com/sgroy10)
|
|
77
84
|
|
|
78
85
|
Usage: speclock <command> [options]
|
|
79
86
|
|
|
80
87
|
Commands:
|
|
81
|
-
setup [--goal <text>]
|
|
88
|
+
setup [--goal <text>] [--template <name>] Full setup: init + SPECLOCK.md + context
|
|
82
89
|
init Initialize SpecLock in current directory
|
|
83
90
|
goal <text> Set or update the project goal
|
|
84
91
|
lock <text> [--tags a,b] Add a non-negotiable constraint
|
|
85
92
|
lock remove <id> Remove a lock by ID
|
|
86
|
-
guard <file> [--lock "text"] Inject lock warning into a file
|
|
87
|
-
unguard <file> Remove lock warning from a file
|
|
93
|
+
guard <file> [--lock "text"] Inject lock warning into a file
|
|
94
|
+
unguard <file> Remove lock warning from a file
|
|
88
95
|
decide <text> [--tags a,b] Record a decision
|
|
89
96
|
note <text> [--pinned] Add a pinned note
|
|
90
97
|
log-change <text> [--files x,y] Log a significant change
|
|
91
98
|
check <text> Check if action conflicts with locks
|
|
99
|
+
template list List available constraint templates
|
|
100
|
+
template apply <name> Apply a template (nextjs, react, etc.)
|
|
101
|
+
report Show violation report + stats
|
|
102
|
+
hook install Install git pre-commit hook
|
|
103
|
+
hook remove Remove git pre-commit hook
|
|
104
|
+
audit Audit staged files against locks
|
|
92
105
|
context Generate and print context pack
|
|
93
106
|
facts deploy [--provider X] Set deployment facts
|
|
94
107
|
watch Start file watcher (auto-track changes)
|
|
@@ -100,17 +113,20 @@ Options:
|
|
|
100
113
|
--source <user|agent> Who created this (default: user)
|
|
101
114
|
--files <a.ts,b.ts> Comma-separated file paths
|
|
102
115
|
--goal <text> Goal text (for setup command)
|
|
116
|
+
--template <name> Template to apply during setup
|
|
103
117
|
--lock <text> Lock text (for guard command)
|
|
104
118
|
--project <path> Project root (for serve)
|
|
105
119
|
|
|
120
|
+
Templates: nextjs, react, express, supabase, stripe, security-hardened
|
|
121
|
+
|
|
106
122
|
Examples:
|
|
107
|
-
npx speclock setup --goal "Build PawPalace pet shop"
|
|
123
|
+
npx speclock setup --goal "Build PawPalace pet shop" --template nextjs
|
|
108
124
|
npx speclock lock "Never modify auth files"
|
|
109
|
-
npx speclock
|
|
125
|
+
npx speclock template apply supabase
|
|
110
126
|
npx speclock check "Adding social login to auth page"
|
|
111
|
-
npx speclock
|
|
112
|
-
npx speclock
|
|
113
|
-
npx speclock
|
|
127
|
+
npx speclock report
|
|
128
|
+
npx speclock hook install
|
|
129
|
+
npx speclock audit
|
|
114
130
|
npx speclock status
|
|
115
131
|
`);
|
|
116
132
|
}
|
|
@@ -188,11 +204,21 @@ async function main() {
|
|
|
188
204
|
console.log("Injected SpecLock marker into package.json.");
|
|
189
205
|
}
|
|
190
206
|
|
|
191
|
-
// 5.
|
|
207
|
+
// 5. Apply template if specified
|
|
208
|
+
if (flags.template) {
|
|
209
|
+
const result = applyTemplate(root, flags.template);
|
|
210
|
+
if (result.applied) {
|
|
211
|
+
console.log(`Applied template "${result.displayName}": ${result.locksAdded} lock(s), ${result.decisionsAdded} decision(s).`);
|
|
212
|
+
} else {
|
|
213
|
+
console.error(`Template error: ${result.error}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 6. Generate context
|
|
192
218
|
generateContext(root);
|
|
193
219
|
console.log("Generated .speclock/context/latest.md");
|
|
194
220
|
|
|
195
|
-
//
|
|
221
|
+
// 7. Print summary
|
|
196
222
|
console.log(`
|
|
197
223
|
SpecLock is ready!
|
|
198
224
|
|
|
@@ -200,16 +226,16 @@ Files created/updated:
|
|
|
200
226
|
.speclock/brain.json — Project memory
|
|
201
227
|
.speclock/context/latest.md — Context for AI (read this)
|
|
202
228
|
SPECLOCK.md — AI rules (read this)
|
|
203
|
-
package.json —
|
|
229
|
+
package.json — Active locks embedded (AI auto-discovery)
|
|
204
230
|
|
|
205
231
|
Next steps:
|
|
206
|
-
The AI should read SPECLOCK.md for rules and
|
|
207
|
-
.speclock/context/latest.md for project context.
|
|
208
|
-
|
|
209
232
|
To add constraints: npx speclock lock "Never touch auth files"
|
|
210
233
|
To check conflicts: npx speclock check "Modifying auth page"
|
|
211
234
|
To log changes: npx speclock log-change "Built landing page"
|
|
212
235
|
To see status: npx speclock status
|
|
236
|
+
|
|
237
|
+
Tip: When starting a new chat, tell the AI:
|
|
238
|
+
"Check speclock status and read the project constraints before doing anything"
|
|
213
239
|
`);
|
|
214
240
|
return;
|
|
215
241
|
}
|
|
@@ -250,6 +276,8 @@ Next steps:
|
|
|
250
276
|
}
|
|
251
277
|
const result = removeLock(root, lockId);
|
|
252
278
|
if (result.removed) {
|
|
279
|
+
// Sync updated locks to package.json
|
|
280
|
+
syncLocksToPackageJson(root);
|
|
253
281
|
refreshContext(root);
|
|
254
282
|
console.log(`Lock removed: "${result.lockText}"`);
|
|
255
283
|
} else {
|
|
@@ -267,6 +295,22 @@ Next steps:
|
|
|
267
295
|
process.exit(1);
|
|
268
296
|
}
|
|
269
297
|
const { lockId } = addLock(root, text, parseTags(flags.tags), flags.source || "user");
|
|
298
|
+
|
|
299
|
+
// Auto-guard related files (Solution 1)
|
|
300
|
+
const guardResult = autoGuardRelatedFiles(root, text);
|
|
301
|
+
if (guardResult.guarded.length > 0) {
|
|
302
|
+
console.log(`Auto-guarded ${guardResult.guarded.length} related file(s):`);
|
|
303
|
+
for (const f of guardResult.guarded) {
|
|
304
|
+
console.log(` 🔒 ${f}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Sync locks to package.json (Solution 2)
|
|
309
|
+
const syncResult = syncLocksToPackageJson(root);
|
|
310
|
+
if (syncResult.success) {
|
|
311
|
+
console.log(`Synced ${syncResult.lockCount} lock(s) to package.json.`);
|
|
312
|
+
}
|
|
313
|
+
|
|
270
314
|
refreshContext(root);
|
|
271
315
|
console.log(`Locked (${lockId}): "${text}"`);
|
|
272
316
|
return;
|
|
@@ -335,8 +379,8 @@ Next steps:
|
|
|
335
379
|
console.log(`\nCONFLICT DETECTED`);
|
|
336
380
|
console.log("=".repeat(50));
|
|
337
381
|
for (const lock of result.conflictingLocks) {
|
|
338
|
-
console.log(` [${lock.
|
|
339
|
-
console.log(` Confidence: ${lock.
|
|
382
|
+
console.log(` [${lock.level}] "${lock.text}"`);
|
|
383
|
+
console.log(` Confidence: ${lock.confidence}%`);
|
|
340
384
|
if (lock.reasons && lock.reasons.length > 0) {
|
|
341
385
|
for (const reason of lock.reasons) {
|
|
342
386
|
console.log(` - ${reason}`);
|
|
@@ -440,6 +484,119 @@ Next steps:
|
|
|
440
484
|
return;
|
|
441
485
|
}
|
|
442
486
|
|
|
487
|
+
// --- TEMPLATE ---
|
|
488
|
+
if (cmd === "template") {
|
|
489
|
+
const sub = args[0];
|
|
490
|
+
if (sub === "list" || !sub) {
|
|
491
|
+
const templates = listTemplates();
|
|
492
|
+
console.log("\nAvailable Templates:");
|
|
493
|
+
console.log("=".repeat(50));
|
|
494
|
+
for (const t of templates) {
|
|
495
|
+
console.log(` ${t.name.padEnd(20)} ${t.displayName} — ${t.description}`);
|
|
496
|
+
console.log(` ${"".padEnd(20)} ${t.lockCount} lock(s), ${t.decisionCount} decision(s)`);
|
|
497
|
+
console.log("");
|
|
498
|
+
}
|
|
499
|
+
console.log("Apply: npx speclock template apply <name>");
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
if (sub === "apply") {
|
|
503
|
+
const name = args[1];
|
|
504
|
+
if (!name) {
|
|
505
|
+
console.error("Error: Template name is required.");
|
|
506
|
+
console.error("Usage: speclock template apply <name>");
|
|
507
|
+
console.error("Run 'speclock template list' to see available templates.");
|
|
508
|
+
process.exit(1);
|
|
509
|
+
}
|
|
510
|
+
const result = applyTemplate(root, name);
|
|
511
|
+
if (result.applied) {
|
|
512
|
+
refreshContext(root);
|
|
513
|
+
console.log(`Template "${result.displayName}" applied successfully!`);
|
|
514
|
+
console.log(` Locks added: ${result.locksAdded}`);
|
|
515
|
+
console.log(` Decisions added: ${result.decisionsAdded}`);
|
|
516
|
+
} else {
|
|
517
|
+
console.error(result.error);
|
|
518
|
+
process.exit(1);
|
|
519
|
+
}
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
console.error(`Unknown template command: ${sub}`);
|
|
523
|
+
console.error("Usage: speclock template list | speclock template apply <name>");
|
|
524
|
+
process.exit(1);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// --- REPORT ---
|
|
528
|
+
if (cmd === "report") {
|
|
529
|
+
const report = generateReport(root);
|
|
530
|
+
console.log("\nSpecLock Violation Report");
|
|
531
|
+
console.log("=".repeat(50));
|
|
532
|
+
console.log(`Total violations blocked: ${report.totalViolations}`);
|
|
533
|
+
if (report.timeRange) {
|
|
534
|
+
console.log(`Period: ${report.timeRange.from.substring(0, 10)} to ${report.timeRange.to.substring(0, 10)}`);
|
|
535
|
+
}
|
|
536
|
+
if (report.mostTestedLocks.length > 0) {
|
|
537
|
+
console.log("\nMost tested locks:");
|
|
538
|
+
for (const lock of report.mostTestedLocks) {
|
|
539
|
+
console.log(` ${lock.count}x — "${lock.text}"`);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
if (report.recentViolations.length > 0) {
|
|
543
|
+
console.log("\nRecent violations:");
|
|
544
|
+
for (const v of report.recentViolations) {
|
|
545
|
+
console.log(` [${v.at.substring(0, 19)}] ${v.topLevel} (${v.topConfidence}%) — "${v.action.substring(0, 60)}"`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
console.log(`\n${report.summary}`);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// --- HOOK ---
|
|
553
|
+
if (cmd === "hook") {
|
|
554
|
+
const sub = args[0];
|
|
555
|
+
if (sub === "install") {
|
|
556
|
+
const result = installHook(root);
|
|
557
|
+
if (result.success) {
|
|
558
|
+
console.log(result.message);
|
|
559
|
+
} else {
|
|
560
|
+
console.error(result.error);
|
|
561
|
+
process.exit(1);
|
|
562
|
+
}
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
if (sub === "remove") {
|
|
566
|
+
const result = removeHook(root);
|
|
567
|
+
if (result.success) {
|
|
568
|
+
console.log(result.message);
|
|
569
|
+
} else {
|
|
570
|
+
console.error(result.error);
|
|
571
|
+
process.exit(1);
|
|
572
|
+
}
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
console.error("Usage: speclock hook install | speclock hook remove");
|
|
576
|
+
process.exit(1);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// --- AUDIT ---
|
|
580
|
+
if (cmd === "audit") {
|
|
581
|
+
const result = auditStagedFiles(root);
|
|
582
|
+
if (result.passed) {
|
|
583
|
+
console.log(result.message);
|
|
584
|
+
process.exit(0);
|
|
585
|
+
} else {
|
|
586
|
+
console.log("\nSPECLOCK AUDIT FAILED");
|
|
587
|
+
console.log("=".repeat(50));
|
|
588
|
+
for (const v of result.violations) {
|
|
589
|
+
console.log(` [${v.severity}] ${v.file}`);
|
|
590
|
+
console.log(` Lock: ${v.lockText}`);
|
|
591
|
+
console.log(` Reason: ${v.reason}`);
|
|
592
|
+
console.log("");
|
|
593
|
+
}
|
|
594
|
+
console.log(result.message);
|
|
595
|
+
console.log("Commit blocked. Unlock files or unstage them to proceed.");
|
|
596
|
+
process.exit(1);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
443
600
|
// --- STATUS ---
|
|
444
601
|
if (cmd === "status") {
|
|
445
602
|
showStatus(root);
|
package/src/core/engine.js
CHANGED
|
@@ -13,8 +13,10 @@ import {
|
|
|
13
13
|
addRecentChange,
|
|
14
14
|
addRevert,
|
|
15
15
|
readEvents,
|
|
16
|
+
addViolation,
|
|
16
17
|
} from "./storage.js";
|
|
17
|
-
import { hasGit, getHead, getDefaultBranch, captureDiff } from "./git.js";
|
|
18
|
+
import { hasGit, getHead, getDefaultBranch, captureDiff, getStagedFiles } from "./git.js";
|
|
19
|
+
import { getTemplateNames, getTemplate } from "./templates.js";
|
|
18
20
|
|
|
19
21
|
// --- Internal helpers ---
|
|
20
22
|
|
|
@@ -385,11 +387,23 @@ export function checkConflict(root, proposedAction) {
|
|
|
385
387
|
)
|
|
386
388
|
.join("\n");
|
|
387
389
|
|
|
388
|
-
|
|
390
|
+
const result = {
|
|
389
391
|
hasConflict: true,
|
|
390
392
|
conflictingLocks: conflicting,
|
|
391
393
|
analysis: `Potential conflict with ${conflicting.length} lock(s):\n${details}\nReview before proceeding.`,
|
|
392
394
|
};
|
|
395
|
+
|
|
396
|
+
// Record violation for reporting
|
|
397
|
+
addViolation(brain, {
|
|
398
|
+
at: nowIso(),
|
|
399
|
+
action: proposedAction,
|
|
400
|
+
locks: conflicting.map((c) => ({ id: c.id, text: c.text, confidence: c.confidence, level: c.level })),
|
|
401
|
+
topLevel: conflicting[0].level,
|
|
402
|
+
topConfidence: conflicting[0].confidence,
|
|
403
|
+
});
|
|
404
|
+
writeBrain(root, brain);
|
|
405
|
+
|
|
406
|
+
return result;
|
|
393
407
|
}
|
|
394
408
|
|
|
395
409
|
// --- Auto-lock suggestions ---
|
|
@@ -777,6 +791,11 @@ npx speclock unguard <file> # Remove file protection
|
|
|
777
791
|
npx speclock lock remove <lockId> # Unlock (only after explicit permission)
|
|
778
792
|
npx speclock log-change "what changed" # Log changes
|
|
779
793
|
npx speclock decide "decision" # Record a decision
|
|
794
|
+
npx speclock template list # List constraint templates
|
|
795
|
+
npx speclock template apply <name> # Apply a template (nextjs, react, etc.)
|
|
796
|
+
npx speclock report # Show violation stats
|
|
797
|
+
npx speclock hook install # Install git pre-commit hook
|
|
798
|
+
npx speclock audit # Audit staged files vs locks
|
|
780
799
|
npx speclock context # Refresh context file
|
|
781
800
|
\`\`\`
|
|
782
801
|
|
|
@@ -849,9 +868,9 @@ export function guardFile(root, relativeFilePath, lockText) {
|
|
|
849
868
|
return { success: true };
|
|
850
869
|
}
|
|
851
870
|
|
|
852
|
-
// --- Package.json
|
|
871
|
+
// --- Package.json lock sync (Solution 2: embed active locks directly in package.json) ---
|
|
853
872
|
|
|
854
|
-
export function
|
|
873
|
+
export function syncLocksToPackageJson(root) {
|
|
855
874
|
const pkgPath = path.join(root, "package.json");
|
|
856
875
|
if (!fs.existsSync(pkgPath)) {
|
|
857
876
|
return { success: false, error: "No package.json found in project root." };
|
|
@@ -860,23 +879,159 @@ export function injectPackageJsonMarker(root) {
|
|
|
860
879
|
try {
|
|
861
880
|
const raw = fs.readFileSync(pkgPath, "utf-8");
|
|
862
881
|
const pkg = JSON.parse(raw);
|
|
882
|
+
const brain = readBrain(root);
|
|
883
|
+
if (!brain) {
|
|
884
|
+
return { success: false, error: "SpecLock not initialized." };
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const activeLocks = brain.specLock.items
|
|
888
|
+
.filter((l) => l.active !== false)
|
|
889
|
+
.map((l) => l.text);
|
|
863
890
|
|
|
864
|
-
// Add speclock marker
|
|
865
891
|
pkg.speclock = {
|
|
866
892
|
active: true,
|
|
867
|
-
|
|
893
|
+
message: "STOP — This project has SpecLock constraints. Read SPECLOCK.md and .speclock/context/latest.md BEFORE making ANY changes. Run 'npx speclock check' before ALL code changes. If a lock below is violated, STOP and ask user to unlock.",
|
|
894
|
+
locks: activeLocks,
|
|
895
|
+
context: ".speclock/context/latest.md",
|
|
896
|
+
rules: "SPECLOCK.md",
|
|
868
897
|
};
|
|
869
898
|
|
|
870
|
-
// Write back with same formatting
|
|
871
899
|
const indent = raw.match(/^(\s+)/m)?.[1] || " ";
|
|
872
900
|
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, indent) + "\n");
|
|
873
901
|
|
|
874
|
-
return { success: true };
|
|
902
|
+
return { success: true, lockCount: activeLocks.length };
|
|
875
903
|
} catch (err) {
|
|
876
|
-
return { success: false, error: `Failed to
|
|
904
|
+
return { success: false, error: `Failed to sync locks to package.json: ${err.message}` };
|
|
877
905
|
}
|
|
878
906
|
}
|
|
879
907
|
|
|
908
|
+
// Backward-compatible alias
|
|
909
|
+
export function injectPackageJsonMarker(root) {
|
|
910
|
+
return syncLocksToPackageJson(root);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// --- Auto-guard related files (Solution 1: scan project and guard files matching lock keywords) ---
|
|
914
|
+
|
|
915
|
+
const FILE_KEYWORD_PATTERNS = [
|
|
916
|
+
{ keywords: ["auth", "authentication", "login", "signup", "signin", "sign-in", "sign-up"], patterns: ["**/Auth*", "**/auth*", "**/Login*", "**/login*", "**/SignUp*", "**/signup*", "**/SignIn*", "**/signin*", "**/*Auth*", "**/*auth*"] },
|
|
917
|
+
{ keywords: ["database", "db", "supabase", "firebase", "mongo", "postgres", "sql", "prisma"], patterns: ["**/supabase*", "**/firebase*", "**/database*", "**/db.*", "**/db/**", "**/prisma/**", "**/*Client*", "**/*client*"] },
|
|
918
|
+
{ keywords: ["payment", "pay", "stripe", "billing", "checkout", "subscription"], patterns: ["**/payment*", "**/Payment*", "**/pay*", "**/Pay*", "**/stripe*", "**/Stripe*", "**/billing*", "**/Billing*", "**/checkout*", "**/Checkout*"] },
|
|
919
|
+
{ keywords: ["api", "endpoint", "route", "routes"], patterns: ["**/api/**", "**/routes/**", "**/endpoints/**"] },
|
|
920
|
+
{ keywords: ["config", "configuration", "settings", "env"], patterns: ["**/config*", "**/Config*", "**/settings*", "**/Settings*"] },
|
|
921
|
+
];
|
|
922
|
+
|
|
923
|
+
function findRelatedFiles(root, lockText) {
|
|
924
|
+
const lockLower = lockText.toLowerCase();
|
|
925
|
+
const matchedFiles = [];
|
|
926
|
+
|
|
927
|
+
// Find which keyword patterns match this lock text
|
|
928
|
+
const matchingPatterns = [];
|
|
929
|
+
for (const group of FILE_KEYWORD_PATTERNS) {
|
|
930
|
+
const hasMatch = group.keywords.some((kw) => lockLower.includes(kw));
|
|
931
|
+
if (hasMatch) {
|
|
932
|
+
matchingPatterns.push(...group.patterns);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
if (matchingPatterns.length === 0) return matchedFiles;
|
|
937
|
+
|
|
938
|
+
// Scan the src/ directory (and common directories) for matching files
|
|
939
|
+
const searchDirs = ["src", "app", "components", "pages", "lib", "utils", "contexts", "hooks", "services"];
|
|
940
|
+
|
|
941
|
+
for (const dir of searchDirs) {
|
|
942
|
+
const dirPath = path.join(root, dir);
|
|
943
|
+
if (!fs.existsSync(dirPath)) continue;
|
|
944
|
+
scanDirForMatches(root, dirPath, matchingPatterns, matchedFiles);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Also check root-level files
|
|
948
|
+
try {
|
|
949
|
+
const rootFiles = fs.readdirSync(root);
|
|
950
|
+
for (const file of rootFiles) {
|
|
951
|
+
const fullPath = path.join(root, file);
|
|
952
|
+
if (!fs.statSync(fullPath).isFile()) continue;
|
|
953
|
+
const ext = path.extname(file).slice(1).toLowerCase();
|
|
954
|
+
if (!GUARD_MARKERS[ext]) continue;
|
|
955
|
+
|
|
956
|
+
for (const pattern of matchingPatterns) {
|
|
957
|
+
const simpleMatch = patternMatchesFile(pattern, file);
|
|
958
|
+
if (simpleMatch) {
|
|
959
|
+
const rel = path.relative(root, fullPath).replace(/\\/g, "/");
|
|
960
|
+
if (!matchedFiles.includes(rel)) matchedFiles.push(rel);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
} catch (_) {}
|
|
965
|
+
|
|
966
|
+
return matchedFiles;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function scanDirForMatches(root, dirPath, patterns, results) {
|
|
970
|
+
try {
|
|
971
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
972
|
+
for (const entry of entries) {
|
|
973
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
974
|
+
if (entry.isDirectory()) {
|
|
975
|
+
if (entry.name === "node_modules" || entry.name === ".speclock" || entry.name === ".git") continue;
|
|
976
|
+
scanDirForMatches(root, fullPath, patterns, results);
|
|
977
|
+
} else if (entry.isFile()) {
|
|
978
|
+
const ext = path.extname(entry.name).slice(1).toLowerCase();
|
|
979
|
+
if (!GUARD_MARKERS[ext]) continue;
|
|
980
|
+
const relPath = path.relative(root, fullPath).replace(/\\/g, "/");
|
|
981
|
+
for (const pattern of patterns) {
|
|
982
|
+
if (patternMatchesFile(pattern, relPath) || patternMatchesFile(pattern, entry.name)) {
|
|
983
|
+
if (!results.includes(relPath)) results.push(relPath);
|
|
984
|
+
break;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
} catch (_) {}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function patternMatchesFile(pattern, filePath) {
|
|
993
|
+
// Simple glob matching: convert glob to regex
|
|
994
|
+
// Handle ** (any path), * (any chars in segment)
|
|
995
|
+
const clean = pattern.replace(/\\/g, "/");
|
|
996
|
+
const fileLower = filePath.toLowerCase();
|
|
997
|
+
const patternLower = clean.toLowerCase();
|
|
998
|
+
|
|
999
|
+
// Strip leading **/ for simple name matching
|
|
1000
|
+
const namePattern = patternLower.replace(/^\*\*\//, "");
|
|
1001
|
+
|
|
1002
|
+
// Check if pattern is just a name pattern (no path separators)
|
|
1003
|
+
if (!namePattern.includes("/")) {
|
|
1004
|
+
const fileName = fileLower.split("/").pop();
|
|
1005
|
+
// Convert glob * to regex .*
|
|
1006
|
+
const regex = new RegExp("^" + namePattern.replace(/\*/g, ".*") + "$");
|
|
1007
|
+
if (regex.test(fileName)) return true;
|
|
1008
|
+
// Also check if the pattern appears anywhere in the filename
|
|
1009
|
+
const corePattern = namePattern.replace(/\*/g, "");
|
|
1010
|
+
if (corePattern.length > 2 && fileName.includes(corePattern)) return true;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// Full path match
|
|
1014
|
+
const regex = new RegExp("^" + patternLower.replace(/\*\*\//g, "(.*/)?").replace(/\*/g, "[^/]*") + "$");
|
|
1015
|
+
return regex.test(fileLower);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
export function autoGuardRelatedFiles(root, lockText) {
|
|
1019
|
+
const relatedFiles = findRelatedFiles(root, lockText);
|
|
1020
|
+
const guarded = [];
|
|
1021
|
+
const skipped = [];
|
|
1022
|
+
|
|
1023
|
+
for (const relFile of relatedFiles) {
|
|
1024
|
+
const result = guardFile(root, relFile, lockText);
|
|
1025
|
+
if (result.success) {
|
|
1026
|
+
guarded.push(relFile);
|
|
1027
|
+
} else {
|
|
1028
|
+
skipped.push({ file: relFile, reason: result.error });
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
return { guarded, skipped, scannedPatterns: relatedFiles.length };
|
|
1033
|
+
}
|
|
1034
|
+
|
|
880
1035
|
export function unguardFile(root, relativeFilePath) {
|
|
881
1036
|
const fullPath = path.join(root, relativeFilePath);
|
|
882
1037
|
if (!fs.existsSync(fullPath)) {
|
|
@@ -906,3 +1061,188 @@ export function unguardFile(root, relativeFilePath) {
|
|
|
906
1061
|
|
|
907
1062
|
return { success: true };
|
|
908
1063
|
}
|
|
1064
|
+
|
|
1065
|
+
// --- Constraint Templates ---
|
|
1066
|
+
|
|
1067
|
+
export function listTemplates() {
|
|
1068
|
+
const names = getTemplateNames();
|
|
1069
|
+
return names.map((name) => {
|
|
1070
|
+
const t = getTemplate(name);
|
|
1071
|
+
return {
|
|
1072
|
+
name: t.name,
|
|
1073
|
+
displayName: t.displayName,
|
|
1074
|
+
description: t.description,
|
|
1075
|
+
lockCount: t.locks.length,
|
|
1076
|
+
decisionCount: t.decisions.length,
|
|
1077
|
+
};
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
export function applyTemplate(root, templateName) {
|
|
1082
|
+
const template = getTemplate(templateName);
|
|
1083
|
+
if (!template) {
|
|
1084
|
+
return { applied: false, error: `Template not found: "${templateName}". Available: ${getTemplateNames().join(", ")}` };
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
ensureInit(root);
|
|
1088
|
+
|
|
1089
|
+
let locksAdded = 0;
|
|
1090
|
+
let decisionsAdded = 0;
|
|
1091
|
+
|
|
1092
|
+
for (const lockText of template.locks) {
|
|
1093
|
+
addLock(root, lockText, [template.name], "agent");
|
|
1094
|
+
autoGuardRelatedFiles(root, lockText);
|
|
1095
|
+
locksAdded++;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
for (const decText of template.decisions) {
|
|
1099
|
+
addDecision(root, decText, [template.name], "agent");
|
|
1100
|
+
decisionsAdded++;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
syncLocksToPackageJson(root);
|
|
1104
|
+
|
|
1105
|
+
return {
|
|
1106
|
+
applied: true,
|
|
1107
|
+
templateName: template.name,
|
|
1108
|
+
displayName: template.displayName,
|
|
1109
|
+
locksAdded,
|
|
1110
|
+
decisionsAdded,
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// --- Violation Report ---
|
|
1115
|
+
|
|
1116
|
+
export function generateReport(root) {
|
|
1117
|
+
const brain = ensureInit(root);
|
|
1118
|
+
const violations = brain.state.violations || [];
|
|
1119
|
+
|
|
1120
|
+
if (violations.length === 0) {
|
|
1121
|
+
return {
|
|
1122
|
+
totalViolations: 0,
|
|
1123
|
+
violationsByLock: {},
|
|
1124
|
+
mostTestedLocks: [],
|
|
1125
|
+
recentViolations: [],
|
|
1126
|
+
summary: "No violations recorded yet. SpecLock is watching.",
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// Count violations per lock
|
|
1131
|
+
const byLock = {};
|
|
1132
|
+
for (const v of violations) {
|
|
1133
|
+
for (const lock of v.locks) {
|
|
1134
|
+
if (!byLock[lock.text]) {
|
|
1135
|
+
byLock[lock.text] = { count: 0, lockId: lock.id, text: lock.text };
|
|
1136
|
+
}
|
|
1137
|
+
byLock[lock.text].count++;
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// Sort by count descending
|
|
1142
|
+
const mostTested = Object.values(byLock).sort((a, b) => b.count - a.count);
|
|
1143
|
+
|
|
1144
|
+
// Recent 10
|
|
1145
|
+
const recent = violations.slice(0, 10).map((v) => ({
|
|
1146
|
+
at: v.at,
|
|
1147
|
+
action: v.action,
|
|
1148
|
+
topLevel: v.topLevel,
|
|
1149
|
+
topConfidence: v.topConfidence,
|
|
1150
|
+
lockCount: v.locks.length,
|
|
1151
|
+
}));
|
|
1152
|
+
|
|
1153
|
+
// Time range
|
|
1154
|
+
const oldest = violations[violations.length - 1];
|
|
1155
|
+
const newest = violations[0];
|
|
1156
|
+
|
|
1157
|
+
return {
|
|
1158
|
+
totalViolations: violations.length,
|
|
1159
|
+
timeRange: { from: oldest.at, to: newest.at },
|
|
1160
|
+
violationsByLock: byLock,
|
|
1161
|
+
mostTestedLocks: mostTested.slice(0, 5),
|
|
1162
|
+
recentViolations: recent,
|
|
1163
|
+
summary: `SpecLock blocked ${violations.length} violation(s). Most tested lock: "${mostTested[0].text}" (${mostTested[0].count} blocks).`,
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// --- Pre-commit Audit ---
|
|
1168
|
+
|
|
1169
|
+
export function auditStagedFiles(root) {
|
|
1170
|
+
const brain = ensureInit(root);
|
|
1171
|
+
const activeLocks = brain.specLock.items.filter((l) => l.active !== false);
|
|
1172
|
+
|
|
1173
|
+
if (activeLocks.length === 0) {
|
|
1174
|
+
return { passed: true, violations: [], checkedFiles: 0, activeLocks: 0, message: "No active locks. Audit passed." };
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
const stagedFiles = getStagedFiles(root);
|
|
1178
|
+
if (stagedFiles.length === 0) {
|
|
1179
|
+
return { passed: true, violations: [], checkedFiles: 0, activeLocks: activeLocks.length, message: "No staged files. Audit passed." };
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
const violations = [];
|
|
1183
|
+
|
|
1184
|
+
for (const file of stagedFiles) {
|
|
1185
|
+
// Check 1: Does the file have a SPECLOCK-GUARD header?
|
|
1186
|
+
const fullPath = path.join(root, file);
|
|
1187
|
+
if (fs.existsSync(fullPath)) {
|
|
1188
|
+
try {
|
|
1189
|
+
const content = fs.readFileSync(fullPath, "utf-8");
|
|
1190
|
+
if (content.includes(GUARD_TAG)) {
|
|
1191
|
+
violations.push({
|
|
1192
|
+
file,
|
|
1193
|
+
reason: "File has SPECLOCK-GUARD header — it is locked and must not be modified",
|
|
1194
|
+
lockText: "(file-level guard)",
|
|
1195
|
+
severity: "HIGH",
|
|
1196
|
+
});
|
|
1197
|
+
continue;
|
|
1198
|
+
}
|
|
1199
|
+
} catch (_) {}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// Check 2: Does the file path match any lock keywords?
|
|
1203
|
+
const fileLower = file.toLowerCase();
|
|
1204
|
+
for (const lock of activeLocks) {
|
|
1205
|
+
const lockLower = lock.text.toLowerCase();
|
|
1206
|
+
const lockHasNegation = hasNegation(lockLower);
|
|
1207
|
+
if (!lockHasNegation) continue;
|
|
1208
|
+
|
|
1209
|
+
// Check if any FILE_KEYWORD_PATTERNS keywords from the lock match this file
|
|
1210
|
+
for (const group of FILE_KEYWORD_PATTERNS) {
|
|
1211
|
+
const lockMatchesKeyword = group.keywords.some((kw) => lockLower.includes(kw));
|
|
1212
|
+
if (!lockMatchesKeyword) continue;
|
|
1213
|
+
|
|
1214
|
+
const fileMatchesPattern = group.patterns.some((pattern) => patternMatchesFile(pattern, fileLower) || patternMatchesFile(pattern, fileLower.split("/").pop()));
|
|
1215
|
+
if (fileMatchesPattern) {
|
|
1216
|
+
violations.push({
|
|
1217
|
+
file,
|
|
1218
|
+
reason: `File matches lock keyword pattern`,
|
|
1219
|
+
lockText: lock.text,
|
|
1220
|
+
severity: "MEDIUM",
|
|
1221
|
+
});
|
|
1222
|
+
break;
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
// Deduplicate by file
|
|
1229
|
+
const seen = new Set();
|
|
1230
|
+
const unique = violations.filter((v) => {
|
|
1231
|
+
if (seen.has(v.file)) return false;
|
|
1232
|
+
seen.add(v.file);
|
|
1233
|
+
return true;
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
const passed = unique.length === 0;
|
|
1237
|
+
const message = passed
|
|
1238
|
+
? `Audit passed. ${stagedFiles.length} file(s) checked against ${activeLocks.length} lock(s).`
|
|
1239
|
+
: `AUDIT FAILED: ${unique.length} violation(s) in ${stagedFiles.length} staged file(s).`;
|
|
1240
|
+
|
|
1241
|
+
return {
|
|
1242
|
+
passed,
|
|
1243
|
+
violations: unique,
|
|
1244
|
+
checkedFiles: stagedFiles.length,
|
|
1245
|
+
activeLocks: activeLocks.length,
|
|
1246
|
+
message,
|
|
1247
|
+
};
|
|
1248
|
+
}
|
package/src/core/git.js
CHANGED
|
@@ -108,3 +108,9 @@ export function getDiffSummary(root) {
|
|
|
108
108
|
if (!res.ok) return "";
|
|
109
109
|
return res.stdout;
|
|
110
110
|
}
|
|
111
|
+
|
|
112
|
+
export function getStagedFiles(root) {
|
|
113
|
+
const res = safeGit(root, ["diff", "--cached", "--name-only"]);
|
|
114
|
+
if (!res.ok || !res.stdout) return [];
|
|
115
|
+
return res.stdout.split("\n").filter(Boolean);
|
|
116
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// SpecLock Git Hook Management
|
|
2
|
+
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import path from "path";
|
|
5
|
+
|
|
6
|
+
const HOOK_MARKER = "# SPECLOCK-HOOK";
|
|
7
|
+
|
|
8
|
+
const HOOK_SCRIPT = `#!/bin/sh
|
|
9
|
+
${HOOK_MARKER} — Do not remove this line
|
|
10
|
+
# SpecLock pre-commit hook: checks staged files against active locks
|
|
11
|
+
# Install: npx speclock hook install
|
|
12
|
+
# Remove: npx speclock hook remove
|
|
13
|
+
|
|
14
|
+
npx speclock audit
|
|
15
|
+
exit $?
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
export function installHook(root) {
|
|
19
|
+
const hooksDir = path.join(root, ".git", "hooks");
|
|
20
|
+
if (!fs.existsSync(path.join(root, ".git"))) {
|
|
21
|
+
return { success: false, error: "Not a git repository. Run 'git init' first." };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Ensure hooks directory exists
|
|
25
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
26
|
+
|
|
27
|
+
const hookPath = path.join(hooksDir, "pre-commit");
|
|
28
|
+
|
|
29
|
+
// Check if existing hook exists (not ours)
|
|
30
|
+
if (fs.existsSync(hookPath)) {
|
|
31
|
+
const existing = fs.readFileSync(hookPath, "utf-8");
|
|
32
|
+
if (existing.includes(HOOK_MARKER)) {
|
|
33
|
+
return { success: false, error: "SpecLock pre-commit hook is already installed." };
|
|
34
|
+
}
|
|
35
|
+
// Append to existing hook
|
|
36
|
+
const appended = existing.trimEnd() + "\n\n" + HOOK_SCRIPT;
|
|
37
|
+
fs.writeFileSync(hookPath, appended, { mode: 0o755 });
|
|
38
|
+
return { success: true, message: "SpecLock hook appended to existing pre-commit hook." };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
fs.writeFileSync(hookPath, HOOK_SCRIPT, { mode: 0o755 });
|
|
42
|
+
return { success: true, message: "SpecLock pre-commit hook installed." };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function removeHook(root) {
|
|
46
|
+
const hookPath = path.join(root, ".git", "hooks", "pre-commit");
|
|
47
|
+
if (!fs.existsSync(hookPath)) {
|
|
48
|
+
return { success: false, error: "No pre-commit hook found." };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const content = fs.readFileSync(hookPath, "utf-8");
|
|
52
|
+
if (!content.includes(HOOK_MARKER)) {
|
|
53
|
+
return { success: false, error: "Pre-commit hook exists but was not installed by SpecLock." };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Check if lines other than our speclock block exist
|
|
57
|
+
const lines = content.split("\n");
|
|
58
|
+
const nonSpeclockLines = lines.filter((line) => {
|
|
59
|
+
const trimmed = line.trim();
|
|
60
|
+
const lower = trimmed.toLowerCase();
|
|
61
|
+
if (!trimmed || trimmed === "#!/bin/sh") return false;
|
|
62
|
+
if (lower.includes("speclock")) return false;
|
|
63
|
+
if (lower.includes("npx speclock")) return false;
|
|
64
|
+
if (trimmed === "exit $?") return false;
|
|
65
|
+
return true;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (nonSpeclockLines.length === 0) {
|
|
69
|
+
// Entire hook was ours — remove file
|
|
70
|
+
fs.unlinkSync(hookPath);
|
|
71
|
+
return { success: true, message: "SpecLock pre-commit hook removed." };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Other hook content exists — remove our block, keep the rest
|
|
75
|
+
const cleaned = content
|
|
76
|
+
.replace(/\n*# SPECLOCK-HOOK[^\n]*\n.*?exit \$\?\n?/s, "\n")
|
|
77
|
+
.trim();
|
|
78
|
+
fs.writeFileSync(hookPath, cleaned + "\n", { mode: 0o755 });
|
|
79
|
+
return { success: true, message: "SpecLock hook removed. Other hook content preserved." };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function isHookInstalled(root) {
|
|
83
|
+
const hookPath = path.join(root, ".git", "hooks", "pre-commit");
|
|
84
|
+
if (!fs.existsSync(hookPath)) return false;
|
|
85
|
+
const content = fs.readFileSync(hookPath, "utf-8");
|
|
86
|
+
return content.includes(HOOK_MARKER);
|
|
87
|
+
}
|
package/src/core/storage.js
CHANGED
|
@@ -73,6 +73,7 @@ export function makeBrain(root, hasGitRepo, defaultBranch) {
|
|
|
73
73
|
},
|
|
74
74
|
recentChanges: [],
|
|
75
75
|
reverts: [],
|
|
76
|
+
violations: [],
|
|
76
77
|
},
|
|
77
78
|
events: { lastEventId: "", count: 0 },
|
|
78
79
|
};
|
|
@@ -104,6 +105,11 @@ export function migrateBrainV1toV2(brain) {
|
|
|
104
105
|
brain.facts.deploy.url = "";
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
// Add violations array if missing
|
|
109
|
+
if (brain.state && !brain.state.violations) {
|
|
110
|
+
brain.state.violations = [];
|
|
111
|
+
}
|
|
112
|
+
|
|
107
113
|
// Remove old importance field
|
|
108
114
|
delete brain.importance;
|
|
109
115
|
|
|
@@ -120,6 +126,10 @@ export function readBrain(root) {
|
|
|
120
126
|
brain = migrateBrainV1toV2(brain);
|
|
121
127
|
writeBrain(root, brain);
|
|
122
128
|
}
|
|
129
|
+
// Ensure violations array exists (added in v1.7.0)
|
|
130
|
+
if (brain.state && !brain.state.violations) {
|
|
131
|
+
brain.state.violations = [];
|
|
132
|
+
}
|
|
123
133
|
return brain;
|
|
124
134
|
}
|
|
125
135
|
|
|
@@ -184,3 +194,11 @@ export function addRecentChange(brain, item) {
|
|
|
184
194
|
export function addRevert(brain, item) {
|
|
185
195
|
brain.state.reverts.unshift(item);
|
|
186
196
|
}
|
|
197
|
+
|
|
198
|
+
export function addViolation(brain, item) {
|
|
199
|
+
if (!brain.state.violations) brain.state.violations = [];
|
|
200
|
+
brain.state.violations.unshift(item);
|
|
201
|
+
if (brain.state.violations.length > 100) {
|
|
202
|
+
brain.state.violations = brain.state.violations.slice(0, 100);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// SpecLock Constraint Templates — Pre-built lock packs for common frameworks
|
|
2
|
+
|
|
3
|
+
export const TEMPLATES = {
|
|
4
|
+
nextjs: {
|
|
5
|
+
name: "nextjs",
|
|
6
|
+
displayName: "Next.js",
|
|
7
|
+
description: "Constraints for Next.js applications — protects routing, API routes, and middleware",
|
|
8
|
+
locks: [
|
|
9
|
+
"Never modify the authentication system without explicit permission",
|
|
10
|
+
"Never change the Next.js routing structure (app/ or pages/ directory layout)",
|
|
11
|
+
"API routes must not expose internal server logic to the client",
|
|
12
|
+
"Middleware must not be modified without review",
|
|
13
|
+
"Environment variables must not be hardcoded in source files",
|
|
14
|
+
],
|
|
15
|
+
decisions: [
|
|
16
|
+
"Framework: Next.js (App Router or Pages Router as configured)",
|
|
17
|
+
"Server components are the default; client components require 'use client' directive",
|
|
18
|
+
],
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
react: {
|
|
22
|
+
name: "react",
|
|
23
|
+
displayName: "React",
|
|
24
|
+
description: "Constraints for React applications — protects state management, component architecture",
|
|
25
|
+
locks: [
|
|
26
|
+
"Never modify the authentication system without explicit permission",
|
|
27
|
+
"Global state management pattern must not change without review",
|
|
28
|
+
"Component prop interfaces must maintain backward compatibility",
|
|
29
|
+
"Shared utility functions must not have breaking changes",
|
|
30
|
+
"Environment variables must not be hardcoded in source files",
|
|
31
|
+
],
|
|
32
|
+
decisions: [
|
|
33
|
+
"Framework: React with functional components and hooks",
|
|
34
|
+
"Styling approach must remain consistent across the project",
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
express: {
|
|
39
|
+
name: "express",
|
|
40
|
+
displayName: "Express.js API",
|
|
41
|
+
description: "Constraints for Express.js backends — protects middleware, routes, and database layer",
|
|
42
|
+
locks: [
|
|
43
|
+
"Never modify authentication or authorization middleware without explicit permission",
|
|
44
|
+
"Database connection configuration must not change without review",
|
|
45
|
+
"No breaking changes to public API endpoints",
|
|
46
|
+
"Rate limiting and security middleware must not be disabled",
|
|
47
|
+
"Environment variables and secrets must not be hardcoded",
|
|
48
|
+
],
|
|
49
|
+
decisions: [
|
|
50
|
+
"Backend: Express.js with REST API pattern",
|
|
51
|
+
"Error handling follows centralized middleware pattern",
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
supabase: {
|
|
56
|
+
name: "supabase",
|
|
57
|
+
displayName: "Supabase",
|
|
58
|
+
description: "Constraints for Supabase projects — protects auth, RLS policies, and database schema",
|
|
59
|
+
locks: [
|
|
60
|
+
"Database must always be Supabase — never switch to another provider",
|
|
61
|
+
"Row Level Security (RLS) policies must not be disabled or weakened",
|
|
62
|
+
"Supabase auth configuration must not change without explicit permission",
|
|
63
|
+
"Database schema migrations must not drop tables or columns without review",
|
|
64
|
+
"Supabase client initialization must not be modified",
|
|
65
|
+
],
|
|
66
|
+
decisions: [
|
|
67
|
+
"Database and auth provider: Supabase",
|
|
68
|
+
"All database access must go through Supabase client (no direct SQL in application code)",
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
stripe: {
|
|
73
|
+
name: "stripe",
|
|
74
|
+
displayName: "Stripe Payments",
|
|
75
|
+
description: "Constraints for Stripe integration — protects payment logic, webhooks, and pricing",
|
|
76
|
+
locks: [
|
|
77
|
+
"Payment processing logic must not be modified without explicit permission",
|
|
78
|
+
"Stripe webhook handlers must not change without review",
|
|
79
|
+
"Pricing and subscription tier definitions must not change without permission",
|
|
80
|
+
"Stripe API keys must never be hardcoded or exposed to the client",
|
|
81
|
+
"Payment error handling must not be weakened or removed",
|
|
82
|
+
],
|
|
83
|
+
decisions: [
|
|
84
|
+
"Payment provider: Stripe",
|
|
85
|
+
"All payment operations must be server-side only",
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
"security-hardened": {
|
|
90
|
+
name: "security-hardened",
|
|
91
|
+
displayName: "Security Hardened",
|
|
92
|
+
description: "Strict security constraints — protects auth, secrets, CORS, input validation",
|
|
93
|
+
locks: [
|
|
94
|
+
"Never modify authentication or authorization without explicit permission",
|
|
95
|
+
"No secrets, API keys, or credentials in source code",
|
|
96
|
+
"CORS configuration must not be loosened without review",
|
|
97
|
+
"Input validation must not be weakened or bypassed",
|
|
98
|
+
"Security headers and CSP must not be removed or weakened",
|
|
99
|
+
"Dependencies must not be downgraded without security review",
|
|
100
|
+
],
|
|
101
|
+
decisions: [
|
|
102
|
+
"Security-first development: all inputs validated, all outputs encoded",
|
|
103
|
+
"Authentication changes require explicit user approval",
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export function getTemplateNames() {
|
|
109
|
+
return Object.keys(TEMPLATES);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function getTemplate(name) {
|
|
113
|
+
return TEMPLATES[name] || null;
|
|
114
|
+
}
|
package/src/mcp/http-server.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SpecLock MCP HTTP Server — for Railway / remote deployment
|
|
3
|
-
* Wraps the same
|
|
3
|
+
* Wraps the same 22 tools as the stdio server using Streamable HTTP transport.
|
|
4
4
|
* Developed by Sandeep Roy (https://github.com/sgroy10)
|
|
5
5
|
*/
|
|
6
6
|
|
|
@@ -23,6 +23,10 @@ import {
|
|
|
23
23
|
endSession,
|
|
24
24
|
suggestLocks,
|
|
25
25
|
detectDrift,
|
|
26
|
+
listTemplates,
|
|
27
|
+
applyTemplate,
|
|
28
|
+
generateReport,
|
|
29
|
+
auditStagedFiles,
|
|
26
30
|
} from "../core/engine.js";
|
|
27
31
|
import { generateContext, generateContextPack } from "../core/context.js";
|
|
28
32
|
import {
|
|
@@ -41,7 +45,7 @@ import {
|
|
|
41
45
|
} from "../core/git.js";
|
|
42
46
|
|
|
43
47
|
const PROJECT_ROOT = process.env.SPECLOCK_PROJECT_ROOT || process.cwd();
|
|
44
|
-
const VERSION = "1.
|
|
48
|
+
const VERSION = "1.7.0";
|
|
45
49
|
const AUTHOR = "Sandeep Roy";
|
|
46
50
|
|
|
47
51
|
function createSpecLockServer() {
|
|
@@ -253,6 +257,41 @@ function createSpecLockServer() {
|
|
|
253
257
|
return { content: [{ type: "text", text: `## SpecLock Health Check\n\nScore: **${score}/100** (Grade: ${grade})\nEvents: ${brain.events.count} | Reverts: ${brain.state.reverts.length}\n\n### Checks\n${checks.join("\n")}${agentTimeline}\n\n---\n*SpecLock v${VERSION} — Developed by ${AUTHOR}*` }] };
|
|
254
258
|
});
|
|
255
259
|
|
|
260
|
+
// Tool 20: speclock_apply_template
|
|
261
|
+
server.tool("speclock_apply_template", "Apply a pre-built constraint template (nextjs, react, express, supabase, stripe, security-hardened).", { name: z.string().optional().describe("Template name. Omit to list.") }, async ({ name }) => {
|
|
262
|
+
ensureInit(PROJECT_ROOT);
|
|
263
|
+
if (!name) {
|
|
264
|
+
const templates = listTemplates();
|
|
265
|
+
const text = templates.map(t => `- **${t.name}** (${t.displayName}): ${t.description} — ${t.lockCount} locks, ${t.decisionCount} decisions`).join("\n");
|
|
266
|
+
return { content: [{ type: "text", text: `## Available Templates\n\n${text}\n\nCall again with a name to apply.` }] };
|
|
267
|
+
}
|
|
268
|
+
const result = applyTemplate(PROJECT_ROOT, name);
|
|
269
|
+
if (!result.applied) return { content: [{ type: "text", text: result.error }], isError: true };
|
|
270
|
+
return { content: [{ type: "text", text: `Template "${result.displayName}" applied: ${result.locksAdded} lock(s) + ${result.decisionsAdded} decision(s).` }] };
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// Tool 21: speclock_report
|
|
274
|
+
server.tool("speclock_report", "Violation report — how many times SpecLock blocked changes.", {}, async () => {
|
|
275
|
+
ensureInit(PROJECT_ROOT);
|
|
276
|
+
const report = generateReport(PROJECT_ROOT);
|
|
277
|
+
const parts = [`## Violation Report`, `Total blocked: **${report.totalViolations}**`];
|
|
278
|
+
if (report.mostTestedLocks.length > 0) {
|
|
279
|
+
parts.push("", "### Most Tested Locks");
|
|
280
|
+
for (const l of report.mostTestedLocks) parts.push(`- ${l.count}x — "${l.text}"`);
|
|
281
|
+
}
|
|
282
|
+
parts.push("", report.summary);
|
|
283
|
+
return { content: [{ type: "text", text: parts.join("\n") }] };
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Tool 22: speclock_audit
|
|
287
|
+
server.tool("speclock_audit", "Audit staged files against active locks.", {}, async () => {
|
|
288
|
+
ensureInit(PROJECT_ROOT);
|
|
289
|
+
const result = auditStagedFiles(PROJECT_ROOT);
|
|
290
|
+
if (result.passed) return { content: [{ type: "text", text: result.message }] };
|
|
291
|
+
const text = result.violations.map(v => `- [${v.severity}] **${v.file}** — ${v.reason}\n Lock: "${v.lockText}"`).join("\n");
|
|
292
|
+
return { content: [{ type: "text", text: `## Audit Failed\n\n${text}\n\n${result.message}` }] };
|
|
293
|
+
});
|
|
294
|
+
|
|
256
295
|
return server;
|
|
257
296
|
}
|
|
258
297
|
|
|
@@ -292,7 +331,7 @@ app.get("/", (req, res) => {
|
|
|
292
331
|
version: VERSION,
|
|
293
332
|
author: AUTHOR,
|
|
294
333
|
description: "AI Continuity Engine — Kill AI amnesia",
|
|
295
|
-
tools:
|
|
334
|
+
tools: 22,
|
|
296
335
|
mcp_endpoint: "/mcp",
|
|
297
336
|
npm: "https://www.npmjs.com/package/speclock",
|
|
298
337
|
github: "https://github.com/sgroy10/speclock",
|
package/src/mcp/server.js
CHANGED
|
@@ -16,6 +16,12 @@ import {
|
|
|
16
16
|
endSession,
|
|
17
17
|
suggestLocks,
|
|
18
18
|
detectDrift,
|
|
19
|
+
syncLocksToPackageJson,
|
|
20
|
+
autoGuardRelatedFiles,
|
|
21
|
+
listTemplates,
|
|
22
|
+
applyTemplate,
|
|
23
|
+
generateReport,
|
|
24
|
+
auditStagedFiles,
|
|
19
25
|
} from "../core/engine.js";
|
|
20
26
|
import { generateContext, generateContextPack } from "../core/context.js";
|
|
21
27
|
import {
|
|
@@ -50,7 +56,7 @@ const PROJECT_ROOT =
|
|
|
50
56
|
args.project || process.env.SPECLOCK_PROJECT_ROOT || process.cwd();
|
|
51
57
|
|
|
52
58
|
// --- MCP Server ---
|
|
53
|
-
const VERSION = "1.
|
|
59
|
+
const VERSION = "1.7.0";
|
|
54
60
|
const AUTHOR = "Sandeep Roy";
|
|
55
61
|
|
|
56
62
|
const server = new McpServer(
|
|
@@ -171,9 +177,19 @@ server.tool(
|
|
|
171
177
|
},
|
|
172
178
|
async ({ text, tags, source }) => {
|
|
173
179
|
const { lockId } = addLock(PROJECT_ROOT, text, tags, source);
|
|
180
|
+
|
|
181
|
+
// Auto-guard related files
|
|
182
|
+
const guardResult = autoGuardRelatedFiles(PROJECT_ROOT, text);
|
|
183
|
+
const guardMsg = guardResult.guarded.length > 0
|
|
184
|
+
? `\nAuto-guarded ${guardResult.guarded.length} file(s): ${guardResult.guarded.join(", ")}`
|
|
185
|
+
: "";
|
|
186
|
+
|
|
187
|
+
// Sync active locks to package.json
|
|
188
|
+
syncLocksToPackageJson(PROJECT_ROOT);
|
|
189
|
+
|
|
174
190
|
return {
|
|
175
191
|
content: [
|
|
176
|
-
{ type: "text", text: `Lock added (${lockId}): "${text}"` },
|
|
192
|
+
{ type: "text", text: `Lock added (${lockId}): "${text}"${guardMsg}` },
|
|
177
193
|
],
|
|
178
194
|
};
|
|
179
195
|
}
|
|
@@ -194,6 +210,8 @@ server.tool(
|
|
|
194
210
|
isError: true,
|
|
195
211
|
};
|
|
196
212
|
}
|
|
213
|
+
// Sync updated locks to package.json
|
|
214
|
+
syncLocksToPackageJson(PROJECT_ROOT);
|
|
197
215
|
return {
|
|
198
216
|
content: [
|
|
199
217
|
{
|
|
@@ -752,6 +770,118 @@ server.tool(
|
|
|
752
770
|
}
|
|
753
771
|
);
|
|
754
772
|
|
|
773
|
+
// ========================================
|
|
774
|
+
// TEMPLATE, REPORT & AUDIT TOOLS (v1.7.0)
|
|
775
|
+
// ========================================
|
|
776
|
+
|
|
777
|
+
// Tool 20: speclock_apply_template
|
|
778
|
+
server.tool(
|
|
779
|
+
"speclock_apply_template",
|
|
780
|
+
"Apply a pre-built constraint template (e.g., nextjs, react, express, supabase, stripe, security-hardened). Templates add recommended locks and decisions for common frameworks.",
|
|
781
|
+
{
|
|
782
|
+
name: z
|
|
783
|
+
.string()
|
|
784
|
+
.optional()
|
|
785
|
+
.describe("Template name to apply. Omit to list available templates."),
|
|
786
|
+
},
|
|
787
|
+
async ({ name }) => {
|
|
788
|
+
if (!name) {
|
|
789
|
+
const templates = listTemplates();
|
|
790
|
+
const formatted = templates
|
|
791
|
+
.map((t) => `- **${t.name}** (${t.displayName}): ${t.description} — ${t.lockCount} locks, ${t.decisionCount} decisions`)
|
|
792
|
+
.join("\n");
|
|
793
|
+
return {
|
|
794
|
+
content: [
|
|
795
|
+
{
|
|
796
|
+
type: "text",
|
|
797
|
+
text: `## Available Templates\n\n${formatted}\n\nCall again with a name to apply.`,
|
|
798
|
+
},
|
|
799
|
+
],
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
const result = applyTemplate(PROJECT_ROOT, name);
|
|
803
|
+
if (!result.applied) {
|
|
804
|
+
return {
|
|
805
|
+
content: [{ type: "text", text: result.error }],
|
|
806
|
+
isError: true,
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
return {
|
|
810
|
+
content: [
|
|
811
|
+
{
|
|
812
|
+
type: "text",
|
|
813
|
+
text: `Template "${result.displayName}" applied: ${result.locksAdded} lock(s) + ${result.decisionsAdded} decision(s) added.`,
|
|
814
|
+
},
|
|
815
|
+
],
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
);
|
|
819
|
+
|
|
820
|
+
// Tool 21: speclock_report
|
|
821
|
+
server.tool(
|
|
822
|
+
"speclock_report",
|
|
823
|
+
"Get a violation report showing how many times SpecLock blocked constraint violations, which locks were tested most, and recent violations.",
|
|
824
|
+
{},
|
|
825
|
+
async () => {
|
|
826
|
+
const report = generateReport(PROJECT_ROOT);
|
|
827
|
+
|
|
828
|
+
const parts = [`## SpecLock Violation Report`, ``, `Total violations blocked: **${report.totalViolations}**`];
|
|
829
|
+
|
|
830
|
+
if (report.timeRange) {
|
|
831
|
+
parts.push(`Period: ${report.timeRange.from.substring(0, 10)} to ${report.timeRange.to.substring(0, 10)}`);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
if (report.mostTestedLocks.length > 0) {
|
|
835
|
+
parts.push("", "### Most Tested Locks");
|
|
836
|
+
for (const lock of report.mostTestedLocks) {
|
|
837
|
+
parts.push(`- ${lock.count}x — "${lock.text}"`);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
if (report.recentViolations.length > 0) {
|
|
842
|
+
parts.push("", "### Recent Violations");
|
|
843
|
+
for (const v of report.recentViolations) {
|
|
844
|
+
parts.push(`- [${v.at.substring(0, 19)}] ${v.topLevel} (${v.topConfidence}%) — "${v.action}"`);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
parts.push("", `---`, report.summary);
|
|
849
|
+
|
|
850
|
+
return {
|
|
851
|
+
content: [{ type: "text", text: parts.join("\n") }],
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
);
|
|
855
|
+
|
|
856
|
+
// Tool 22: speclock_audit
|
|
857
|
+
server.tool(
|
|
858
|
+
"speclock_audit",
|
|
859
|
+
"Audit git staged files against active SpecLock constraints. Returns pass/fail with details on which files violate locks. Used by the pre-commit hook.",
|
|
860
|
+
{},
|
|
861
|
+
async () => {
|
|
862
|
+
const result = auditStagedFiles(PROJECT_ROOT);
|
|
863
|
+
|
|
864
|
+
if (result.passed) {
|
|
865
|
+
return {
|
|
866
|
+
content: [{ type: "text", text: result.message }],
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
const formatted = result.violations
|
|
871
|
+
.map((v) => `- [${v.severity}] **${v.file}** — ${v.reason}\n Lock: "${v.lockText}"`)
|
|
872
|
+
.join("\n");
|
|
873
|
+
|
|
874
|
+
return {
|
|
875
|
+
content: [
|
|
876
|
+
{
|
|
877
|
+
type: "text",
|
|
878
|
+
text: `## Audit Failed\n\n${formatted}\n\n${result.message}`,
|
|
879
|
+
},
|
|
880
|
+
],
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
);
|
|
884
|
+
|
|
755
885
|
// --- Smithery sandbox export ---
|
|
756
886
|
export default function createSandboxServer() {
|
|
757
887
|
return server;
|