speclock 4.1.2 → 4.3.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "speclock",
3
- "version": "4.1.2",
4
- "description": "AI constraint engine with Policy-as-Code DSL, OAuth/OIDC SSO, admin dashboard, telemetry, API key auth, RBAC, AES-256-GCM encryption, hard enforcement, semantic pre-commit, HMAC audit chain, SOC 2/HIPAA compliance. 100% detection, 0% false positives. 31 MCP tools + CLI. Enterprise platform.",
3
+ "version": "4.3.0",
4
+ "description": "AI constraint engine with Gemini LLM universal detection, Policy-as-Code DSL, OAuth/OIDC SSO, admin dashboard, telemetry, API key auth, RBAC, AES-256-GCM encryption, hard enforcement, semantic pre-commit, HMAC audit chain, SOC 2/HIPAA compliance. Cross-platform: MCP + direct API. 31 MCP tools + CLI. Enterprise platform.",
5
5
  "type": "module",
6
6
  "main": "src/mcp/server.js",
7
7
  "bin": {
package/src/cli/index.js CHANGED
@@ -116,7 +116,7 @@ function refreshContext(root) {
116
116
 
117
117
  function printHelp() {
118
118
  console.log(`
119
- SpecLock v3.5.0 — AI Constraint Engine (Policy-as-Code + SSO + Dashboard + Telemetry + Auth + RBAC + Encryption)
119
+ SpecLock v4.3.0 — AI Constraint Engine (Gemini LLM + Policy-as-Code + SSO + Dashboard + Telemetry + Auth + RBAC + Encryption)
120
120
  Developed by Sandeep Roy (github.com/sgroy10)
121
121
 
122
122
  Usage: speclock <command> [options]
@@ -9,7 +9,7 @@
9
9
  import { readBrain, readEvents } from "./storage.js";
10
10
  import { verifyAuditChain } from "./audit.js";
11
11
 
12
- const VERSION = "3.5.4";
12
+ const VERSION = "4.3.0";
13
13
 
14
14
  // PHI-related keywords for HIPAA filtering
15
15
  const PHI_KEYWORDS = [
@@ -56,13 +56,95 @@ const GUARD_TAG = "SPECLOCK-GUARD";
56
56
 
57
57
  // --- Core functions ---
58
58
 
59
- export function checkConflict(root, proposedAction) {
59
+ /**
60
+ * Detect if the first argument is a file-system path (brain mode)
61
+ * or natural text (direct mode for cross-platform usage).
62
+ */
63
+ function isDirectoryPath(str) {
64
+ if (!str || typeof str !== "string") return false;
65
+ // Absolute paths: /foo, C:\foo, \\server
66
+ if (str.startsWith("/") || str.startsWith("\\") || /^[A-Z]:/i.test(str)) return true;
67
+ // Relative path with separator or current dir
68
+ if (str === "." || str === ".." || str.includes("/") || str.includes("\\")) return true;
69
+ return false;
70
+ }
71
+
72
+ /**
73
+ * Direct-mode conflict check: action text + lock text(s) directly.
74
+ * No brain.json needed. Works on any platform.
75
+ * @param {string} actionText - The proposed action
76
+ * @param {string|string[]} locks - Lock text or array of lock texts
77
+ * @returns {Object} Same shape as brain-mode checkConflict
78
+ */
79
+ function checkConflictDirect(actionText, locks) {
80
+ const lockList = Array.isArray(locks) ? locks : [locks];
81
+ const conflicting = [];
82
+ let maxNonConflictScore = 0;
83
+
84
+ for (const lockText of lockList) {
85
+ const result = analyzeConflict(actionText, lockText);
86
+ if (result.isConflict) {
87
+ conflicting.push({
88
+ id: "direct",
89
+ text: lockText,
90
+ matchedKeywords: [],
91
+ confidence: result.confidence,
92
+ level: result.level,
93
+ reasons: result.reasons,
94
+ });
95
+ } else if (result.confidence > maxNonConflictScore) {
96
+ maxNonConflictScore = result.confidence;
97
+ }
98
+ }
99
+
100
+ if (conflicting.length === 0) {
101
+ return {
102
+ hasConflict: false,
103
+ conflictingLocks: [],
104
+ _maxNonConflictScore: maxNonConflictScore,
105
+ analysis: `Checked against ${lockList.length} lock(s). No conflicts detected.`,
106
+ };
107
+ }
108
+
109
+ conflicting.sort((a, b) => b.confidence - a.confidence);
110
+ const details = conflicting
111
+ .map(
112
+ (c) =>
113
+ `- [${c.level}] "${c.text}" (confidence: ${c.confidence}%)\n Reasons: ${c.reasons.join("; ")}`
114
+ )
115
+ .join("\n");
116
+
117
+ return {
118
+ hasConflict: true,
119
+ conflictingLocks: conflicting,
120
+ _maxNonConflictScore: maxNonConflictScore,
121
+ analysis: `Potential conflict with ${conflicting.length} lock(s):\n${details}\nReview before proceeding.`,
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Check conflicts. Supports TWO calling patterns:
127
+ * 1. Brain mode: checkConflict(rootDir, proposedAction)
128
+ * 2. Direct mode: checkConflict(actionText, lockText)
129
+ * checkConflict(actionText, [lock1, lock2, ...])
130
+ * Automatically detects which mode based on the first argument.
131
+ */
132
+ export function checkConflict(rootOrAction, proposedActionOrLock) {
133
+ // Direct mode: first arg is not a directory path → treat as (action, lock)
134
+ if (!isDirectoryPath(rootOrAction)) {
135
+ return checkConflictDirect(rootOrAction, proposedActionOrLock);
136
+ }
137
+
138
+ // Brain mode: first arg is a directory path → read locks from brain.json
139
+ const root = rootOrAction;
140
+ const proposedAction = proposedActionOrLock;
60
141
  const brain = ensureInit(root);
61
- const activeLocks = brain.specLock.items.filter((l) => l.active !== false);
142
+ const activeLocks = (brain.specLock?.items || []).filter((l) => l.active !== false);
62
143
  if (activeLocks.length === 0) {
63
144
  return {
64
145
  hasConflict: false,
65
146
  conflictingLocks: [],
147
+ _maxNonConflictScore: 0,
66
148
  analysis: "No active locks. No constraints to check against.",
67
149
  };
68
150
  }
@@ -124,25 +206,17 @@ export function checkConflict(root, proposedAction) {
124
206
 
125
207
  /**
126
208
  * Async conflict check with LLM fallback for grey-zone cases.
209
+ * Supports both brain mode and direct mode (same as checkConflict).
127
210
  * Strategy: Run heuristic first (fast, free, offline).
128
211
  * - Score > 70% on ALL conflicts → trust heuristic (skip LLM)
129
- * - Score == 0 everywhere (no signal at all) → trust heuristic (skip LLM)
130
- * - Score 1–70% on ANY lock → GREY ZONE → call LLM for universal domain coverage
131
- * This catches vocabulary gaps where the heuristic has partial/no signal
132
- * but an LLM (which knows every domain) would detect the conflict.
212
+ * - Everything else call LLM for universal domain coverage
133
213
  */
134
- export async function checkConflictAsync(root, proposedAction) {
135
- // 1. Always run the fast heuristic first
136
- const heuristicResult = checkConflict(root, proposedAction);
137
-
138
- // 2. Determine the max score across ALL locks (conflict + non-conflict)
139
- const maxConflictScore = heuristicResult.conflictingLocks.length > 0
140
- ? Math.max(...heuristicResult.conflictingLocks.map((c) => c.confidence))
141
- : 0;
142
- const maxNonConflictScore = heuristicResult._maxNonConflictScore || 0;
143
- const maxScore = Math.max(maxConflictScore, maxNonConflictScore);
144
-
145
- // 3. Fast path: all conflicts are HIGH (>70%) → heuristic is certain, skip LLM
214
+ export async function checkConflictAsync(rootOrAction, proposedActionOrLock) {
215
+ // 1. Always run the fast heuristic first (handles both brain + direct mode)
216
+ const heuristicResult = checkConflict(rootOrAction, proposedActionOrLock);
217
+ const isDirect = !isDirectoryPath(rootOrAction);
218
+
219
+ // 2. Fast path: all conflicts are HIGH (>70%) → heuristic is certain, skip LLM
146
220
  if (
147
221
  heuristicResult.hasConflict &&
148
222
  heuristicResult.conflictingLocks.every((c) => c.confidence > 70)
@@ -150,12 +224,27 @@ export async function checkConflictAsync(root, proposedAction) {
150
224
  return heuristicResult;
151
225
  }
152
226
 
153
- // 4. Call LLM for everything else — including score 0.
227
+ // 3. Call LLM for everything else — including score 0.
154
228
  // Score 0 means "heuristic vocabulary doesn't cover this domain",
155
229
  // which is EXACTLY when an LLM (which knows every domain) adds value.
156
230
  try {
157
231
  const { llmCheckConflict } = await import("./llm-checker.js");
158
- const llmResult = await llmCheckConflict(root, proposedAction);
232
+ // In direct mode, build activeLocks from the lock text(s) passed directly
233
+ let llmResult;
234
+ if (isDirect) {
235
+ const lockTexts = Array.isArray(proposedActionOrLock)
236
+ ? proposedActionOrLock
237
+ : [proposedActionOrLock];
238
+ const activeLocks = lockTexts.map((t, i) => ({
239
+ id: `direct-${i}`,
240
+ text: t,
241
+ active: true,
242
+ }));
243
+ llmResult = await llmCheckConflict(null, rootOrAction, activeLocks);
244
+ } else {
245
+ llmResult = await llmCheckConflict(rootOrAction, proposedActionOrLock);
246
+ }
247
+
159
248
  if (llmResult) {
160
249
  // Keep HIGH heuristic conflicts (>70%) — they're already certain
161
250
  const highConfidence = heuristicResult.conflictingLocks.filter(
@@ -44,6 +44,9 @@ export {
44
44
  auditStagedFiles,
45
45
  } from "./conflict.js";
46
46
 
47
+ // --- Semantic Analysis (direct API for cross-platform usage) ---
48
+ export { analyzeConflict, scoreConflict } from "./semantics.js";
49
+
47
50
  // --- Sessions ---
48
51
  export {
49
52
  startSession,
@@ -173,7 +173,7 @@ async function callAnthropic(apiKey, userPrompt) {
173
173
 
174
174
  async function callGemini(apiKey, userPrompt) {
175
175
  const resp = await fetch(
176
- `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`,
176
+ `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`,
177
177
  {
178
178
  method: "POST",
179
179
  headers: { "Content-Type": "application/json" },
@@ -379,8 +379,7 @@ export const CONCEPT_MAP = {
379
379
  "anti-fraud"],
380
380
  "posting": ["transaction", "ledger entry", "journal entry", "record"],
381
381
  "reconciliation": ["balance", "ledger", "account", "transaction", "audit"],
382
- "checkout": ["payment", "cart", "purchase", "transaction", "billing",
383
- "payment processing", "order"],
382
+ "checkout": ["cart", "purchase", "order"],
384
383
  "revenue": ["payment", "billing", "income", "sales", "earnings",
385
384
  "transaction"],
386
385
  "invoice": ["billing", "payment", "charge", "transaction", "accounts receivable"],
@@ -425,9 +424,9 @@ export const CONCEPT_MAP = {
425
424
 
426
425
  // E-commerce
427
426
  "cart": ["checkout", "purchase", "shopping cart"],
428
- "payment processing":["payment", "checkout", "billing", "transaction",
427
+ "payment processing":["payment", "billing", "transaction",
429
428
  "stripe", "payment gateway"],
430
- "payment gateway": ["payment processing", "stripe", "paypal", "checkout",
429
+ "payment gateway": ["payment processing", "stripe", "paypal",
431
430
  "billing", "transaction"],
432
431
  "product": ["item", "sku", "catalog", "merchandise", "product listing"],
433
432
  "price": ["pricing", "cost", "amount", "rate", "charge"],
@@ -1386,17 +1385,35 @@ function _compareSubjectsInline(actionText, lockText) {
1386
1385
  };
1387
1386
  }
1388
1387
 
1388
+ // --- Performance cache: avoid re-computing action-side analysis across multiple locks ---
1389
+ const _actionCache = new Map();
1390
+ const _ACTION_CACHE_MAX = 50;
1391
+
1392
+ function _getCachedAction(text) {
1393
+ const cached = _actionCache.get(text);
1394
+ if (cached) return cached;
1395
+ const tokens = tokenize(text);
1396
+ const expanded = expandSemantics(tokens.all);
1397
+ const intent = classifyIntent(text);
1398
+ const temporal = detectTemporalModifier(text);
1399
+ const entry = { tokens, expanded, intent, temporal };
1400
+ if (_actionCache.size >= _ACTION_CACHE_MAX) {
1401
+ _actionCache.delete(_actionCache.keys().next().value);
1402
+ }
1403
+ _actionCache.set(text, entry);
1404
+ return entry;
1405
+ }
1406
+
1389
1407
  export function scoreConflict({ actionText, lockText }) {
1390
- const actionTokens = tokenize(actionText);
1391
- const lockTokens = tokenize(lockText);
1408
+ const actionCached = _getCachedAction(actionText);
1409
+ const actionTokens = actionCached.tokens;
1410
+ const actionExpanded = actionCached.expanded;
1411
+ const actionIntent = actionCached.intent;
1412
+ const hasTemporalMod = actionCached.temporal;
1392
1413
 
1393
- const actionExpanded = expandSemantics(actionTokens.all);
1414
+ const lockTokens = tokenize(lockText);
1394
1415
  const lockExpanded = expandSemantics(lockTokens.all);
1395
-
1396
- const actionIntent = classifyIntent(actionText);
1397
1416
  const lockIntent = classifyIntent(lockText);
1398
-
1399
- const hasTemporalMod = detectTemporalModifier(actionText);
1400
1417
  const lockIsProhibitive = isProhibitiveLock(lockText);
1401
1418
 
1402
1419
  let score = 0;
@@ -257,7 +257,7 @@ export async function flushToRemote(root) {
257
257
  // Build anonymized payload
258
258
  const payload = {
259
259
  instanceId: summary.instanceId,
260
- version: "3.5.0",
260
+ version: "4.3.0",
261
261
  totalCalls: summary.totalCalls,
262
262
  avgResponseMs: summary.avgResponseMs,
263
263
  conflicts: summary.conflicts,
@@ -89,7 +89,7 @@
89
89
  <div class="header">
90
90
  <div>
91
91
  <h1><span>SpecLock</span> Dashboard</h1>
92
- <div class="meta">v3.5.0 &mdash; AI Constraint Engine</div>
92
+ <div class="meta">v4.3.0 &mdash; AI Constraint Engine</div>
93
93
  </div>
94
94
  <div style="display:flex;align-items:center;gap:12px;">
95
95
  <span id="health-badge" class="status-badge healthy">Loading...</span>
@@ -182,7 +182,7 @@
182
182
  </div>
183
183
 
184
184
  <div style="text-align:center;padding:24px;color:var(--muted);font-size:12px;">
185
- SpecLock v3.5.0 &mdash; Developed by Sandeep Roy &mdash; <a href="https://github.com/sgroy10/speclock" style="color:var(--accent)">GitHub</a>
185
+ SpecLock v4.3.0 &mdash; Developed by Sandeep Roy &mdash; <a href="https://github.com/sgroy10/speclock" style="color:var(--accent)">GitHub</a>
186
186
  </div>
187
187
 
188
188
  <script>
@@ -91,7 +91,7 @@ import { fileURLToPath } from "url";
91
91
  import _path from "path";
92
92
 
93
93
  const PROJECT_ROOT = process.env.SPECLOCK_PROJECT_ROOT || process.cwd();
94
- const VERSION = "3.5.4";
94
+ const VERSION = "4.3.0";
95
95
  const AUTHOR = "Sandeep Roy";
96
96
  const START_TIME = Date.now();
97
97
 
package/src/mcp/server.js CHANGED
@@ -100,7 +100,7 @@ const PROJECT_ROOT =
100
100
  args.project || process.env.SPECLOCK_PROJECT_ROOT || process.cwd();
101
101
 
102
102
  // --- MCP Server ---
103
- const VERSION = "3.5.4";
103
+ const VERSION = "4.3.0";
104
104
  const AUTHOR = "Sandeep Roy";
105
105
 
106
106
  const server = new McpServer(
@@ -521,48 +521,54 @@ server.tool(
521
521
  .describe("Which AI tool is being used"),
522
522
  },
523
523
  async ({ toolName }) => {
524
- const briefing = getSessionBriefing(PROJECT_ROOT, toolName);
525
- const contextMd = generateContext(PROJECT_ROOT);
526
-
527
- const parts = [];
528
-
529
- // Session info
530
- parts.push(`# SpecLock Session Briefing`);
531
- parts.push(`Session started (${toolName}). ID: ${briefing.session.id}`);
532
- parts.push("");
533
-
534
- // Last session summary
535
- if (briefing.lastSession) {
536
- parts.push("## Last Session");
537
- parts.push(`- Tool: **${briefing.lastSession.toolUsed}**`);
538
- parts.push(`- Ended: ${briefing.lastSession.endedAt || "unknown"}`);
539
- if (briefing.lastSession.summary)
540
- parts.push(`- Summary: ${briefing.lastSession.summary}`);
541
- parts.push(
542
- `- Events: ${briefing.lastSession.eventsInSession || 0}`
543
- );
544
- parts.push(
545
- `- Changes since then: ${briefing.changesSinceLastSession}`
546
- );
524
+ try {
525
+ const briefing = getSessionBriefing(PROJECT_ROOT, toolName);
526
+ const contextMd = generateContext(PROJECT_ROOT);
527
+
528
+ const parts = [];
529
+
530
+ // Session info
531
+ parts.push(`# SpecLock Session Briefing`);
532
+ parts.push(`Session started (${toolName}). ID: ${briefing.session?.id || "new"}`);
547
533
  parts.push("");
548
- }
549
534
 
550
- // Warnings
551
- if (briefing.warnings.length > 0) {
552
- parts.push("## Warnings");
553
- for (const w of briefing.warnings) {
554
- parts.push(`- ${w}`);
535
+ // Last session summary
536
+ if (briefing.lastSession) {
537
+ parts.push("## Last Session");
538
+ parts.push(`- Tool: **${briefing.lastSession.toolUsed || "unknown"}**`);
539
+ parts.push(`- Ended: ${briefing.lastSession.endedAt || "unknown"}`);
540
+ if (briefing.lastSession.summary)
541
+ parts.push(`- Summary: ${briefing.lastSession.summary}`);
542
+ parts.push(
543
+ `- Events: ${briefing.lastSession.eventsInSession || 0}`
544
+ );
545
+ parts.push(
546
+ `- Changes since then: ${briefing.changesSinceLastSession || 0}`
547
+ );
548
+ parts.push("");
555
549
  }
556
- parts.push("");
557
- }
558
550
 
559
- // Full context
560
- parts.push("---");
561
- parts.push(contextMd);
551
+ // Warnings
552
+ if (briefing.warnings?.length > 0) {
553
+ parts.push("## Warnings");
554
+ for (const w of briefing.warnings) {
555
+ parts.push(`- ${w}`);
556
+ }
557
+ parts.push("");
558
+ }
562
559
 
563
- return {
564
- content: [{ type: "text", text: parts.join("\n") }],
565
- };
560
+ // Full context
561
+ parts.push("---");
562
+ parts.push(contextMd);
563
+
564
+ return {
565
+ content: [{ type: "text", text: parts.join("\n") }],
566
+ };
567
+ } catch (err) {
568
+ return {
569
+ content: [{ type: "text", text: `# SpecLock Session Briefing\n\nError loading session: ${err.message}\n\nTry running speclock_init first.\n\n---\n*SpecLock v${VERSION}*` }],
570
+ };
571
+ }
566
572
  }
567
573
  );
568
574
 
@@ -780,68 +786,78 @@ server.tool(
780
786
  "Get a health check of the SpecLock setup including completeness score, missing recommended items, and multi-agent session timeline.",
781
787
  {},
782
788
  async () => {
783
- const brain = ensureInit(PROJECT_ROOT);
784
- const activeLocks = brain.specLock.items.filter((l) => l.active !== false);
789
+ try {
790
+ const brain = ensureInit(PROJECT_ROOT);
791
+ const activeLocks = (brain.specLock?.items || []).filter((l) => l.active !== false);
785
792
 
786
- // Calculate health score
787
- let score = 0;
788
- const checks = [];
793
+ // Calculate health score
794
+ let score = 0;
795
+ const checks = [];
789
796
 
790
- if (brain.goal.text) { score += 20; checks.push("[PASS] Goal is set"); }
791
- else checks.push("[MISS] No project goal set");
797
+ if (brain.goal?.text) { score += 20; checks.push("[PASS] Goal is set"); }
798
+ else checks.push("[MISS] No project goal set");
792
799
 
793
- if (activeLocks.length > 0) { score += 25; checks.push(`[PASS] ${activeLocks.length} active lock(s)`); }
794
- else checks.push("[MISS] No SpecLock constraints defined");
800
+ if (activeLocks.length > 0) { score += 25; checks.push(`[PASS] ${activeLocks.length} active lock(s)`); }
801
+ else checks.push("[MISS] No SpecLock constraints defined");
795
802
 
796
- if (brain.decisions.length > 0) { score += 15; checks.push(`[PASS] ${brain.decisions.length} decision(s) recorded`); }
797
- else checks.push("[MISS] No decisions recorded");
803
+ if ((brain.decisions || []).length > 0) { score += 15; checks.push(`[PASS] ${brain.decisions.length} decision(s) recorded`); }
804
+ else checks.push("[MISS] No decisions recorded");
798
805
 
799
- if (brain.notes.length > 0) { score += 10; checks.push(`[PASS] ${brain.notes.length} note(s)`); }
800
- else checks.push("[MISS] No notes added");
806
+ if ((brain.notes || []).length > 0) { score += 10; checks.push(`[PASS] ${brain.notes.length} note(s)`); }
807
+ else checks.push("[MISS] No notes added");
801
808
 
802
- if (brain.sessions.history.length > 0) { score += 15; checks.push(`[PASS] ${brain.sessions.history.length} session(s) in history`); }
803
- else checks.push("[MISS] No session history yet");
809
+ const sessionHistory = brain.sessions?.history || [];
810
+ if (sessionHistory.length > 0) { score += 15; checks.push(`[PASS] ${sessionHistory.length} session(s) in history`); }
811
+ else checks.push("[MISS] No session history yet");
804
812
 
805
- if (brain.state.recentChanges.length > 0) { score += 10; checks.push(`[PASS] ${brain.state.recentChanges.length} change(s) tracked`); }
806
- else checks.push("[MISS] No changes tracked");
813
+ const recentChanges = brain.state?.recentChanges || [];
814
+ if (recentChanges.length > 0) { score += 10; checks.push(`[PASS] ${recentChanges.length} change(s) tracked`); }
815
+ else checks.push("[MISS] No changes tracked");
807
816
 
808
- if (brain.facts.deploy.provider !== "unknown") { score += 5; checks.push("[PASS] Deploy facts configured"); }
809
- else checks.push("[MISS] Deploy facts not configured");
817
+ if (brain.facts?.deploy?.provider && brain.facts.deploy.provider !== "unknown") { score += 5; checks.push("[PASS] Deploy facts configured"); }
818
+ else checks.push("[MISS] Deploy facts not configured");
810
819
 
811
- // Multi-agent timeline
812
- const agentMap = {};
813
- for (const session of brain.sessions.history) {
814
- const tool = session.toolUsed || "unknown";
815
- if (!agentMap[tool]) agentMap[tool] = { count: 0, lastUsed: "", summaries: [] };
816
- agentMap[tool].count++;
817
- if (!agentMap[tool].lastUsed || session.endedAt > agentMap[tool].lastUsed) {
818
- agentMap[tool].lastUsed = session.endedAt || session.startedAt;
819
- }
820
- if (session.summary && agentMap[tool].summaries.length < 3) {
821
- agentMap[tool].summaries.push(session.summary.substring(0, 80));
820
+ // Multi-agent timeline
821
+ const agentMap = {};
822
+ for (const session of sessionHistory) {
823
+ const tool = session.toolUsed || "unknown";
824
+ if (!agentMap[tool]) agentMap[tool] = { count: 0, lastUsed: "", summaries: [] };
825
+ agentMap[tool].count++;
826
+ if (!agentMap[tool].lastUsed || (session.endedAt && session.endedAt > agentMap[tool].lastUsed)) {
827
+ agentMap[tool].lastUsed = session.endedAt || session.startedAt || "";
828
+ }
829
+ if (session.summary && agentMap[tool].summaries.length < 3) {
830
+ agentMap[tool].summaries.push(session.summary.substring(0, 80));
831
+ }
822
832
  }
823
- }
824
833
 
825
- let agentTimeline = "";
826
- if (Object.keys(agentMap).length > 0) {
827
- agentTimeline = "\n\n## Multi-Agent Timeline\n" +
828
- Object.entries(agentMap)
829
- .map(([tool, info]) =>
830
- `- **${tool}**: ${info.count} session(s), last active ${info.lastUsed ? info.lastUsed.substring(0, 16) : "unknown"}\n Recent: ${info.summaries.length > 0 ? info.summaries.map(s => `"${s}"`).join(", ") : "(no summaries)"}`
831
- )
832
- .join("\n");
833
- }
834
+ let agentTimeline = "";
835
+ if (Object.keys(agentMap).length > 0) {
836
+ agentTimeline = "\n\n## Multi-Agent Timeline\n" +
837
+ Object.entries(agentMap)
838
+ .map(([tool, info]) =>
839
+ `- **${tool}**: ${info.count} session(s), last active ${info.lastUsed ? info.lastUsed.substring(0, 16) : "unknown"}\n Recent: ${info.summaries.length > 0 ? info.summaries.map(s => `"${s}"`).join(", ") : "(no summaries)"}`
840
+ )
841
+ .join("\n");
842
+ }
834
843
 
835
- const grade = score >= 80 ? "A" : score >= 60 ? "B" : score >= 40 ? "C" : score >= 20 ? "D" : "F";
844
+ const grade = score >= 80 ? "A" : score >= 60 ? "B" : score >= 40 ? "C" : score >= 20 ? "D" : "F";
845
+ const evtCount = brain.events?.count || 0;
846
+ const revertCount = (brain.state?.reverts || []).length;
836
847
 
837
- return {
838
- content: [
839
- {
840
- type: "text",
841
- 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}*`,
842
- },
843
- ],
844
- };
848
+ return {
849
+ content: [
850
+ {
851
+ type: "text",
852
+ text: `## SpecLock Health Check\n\nScore: **${score}/100** (Grade: ${grade})\nEvents: ${evtCount} | Reverts: ${revertCount}\n\n### Checks\n${checks.join("\n")}${agentTimeline}\n\n---\n*SpecLock v${VERSION} — Developed by ${AUTHOR}*`,
853
+ },
854
+ ],
855
+ };
856
+ } catch (err) {
857
+ return {
858
+ content: [{ type: "text", text: `## SpecLock Health Check\n\nError: ${err.message}\n\nTry running speclock_init first to initialize the project.\n\n---\n*SpecLock v${VERSION}*` }],
859
+ };
860
+ }
845
861
  }
846
862
  );
847
863