speclock 5.2.6 → 5.3.1

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.
@@ -0,0 +1,236 @@
1
+ /**
2
+ * SpecLock Incident Replay — Session activity log
3
+ * Shows exactly what AI agents tried and what SpecLock caught.
4
+ *
5
+ * Developed by Sandeep Roy (https://github.com/sgroy10)
6
+ */
7
+
8
+ import { readBrain, readEvents } from "./storage.js";
9
+ import { ensureInit } from "./memory.js";
10
+
11
+ /**
12
+ * Get a formatted replay of a session's events.
13
+ *
14
+ * @param {string} root - Project root
15
+ * @param {Object} options
16
+ * @param {string} [options.sessionId] - Specific session ID to replay. If omitted, replays last session.
17
+ * @param {number} [options.limit] - Max events to show (default: 50)
18
+ * @returns {Object} Replay data with events, stats, and formatted output
19
+ */
20
+ export function getReplay(root, options = {}) {
21
+ const brain = ensureInit(root);
22
+ const allSessions = [
23
+ ...(brain.sessions.current ? [brain.sessions.current] : []),
24
+ ...brain.sessions.history,
25
+ ];
26
+
27
+ if (allSessions.length === 0) {
28
+ return {
29
+ found: false,
30
+ error: "No sessions recorded yet. Start a session with speclock_session_briefing.",
31
+ events: [],
32
+ stats: { total: 0, allowed: 0, warned: 0, blocked: 0 },
33
+ };
34
+ }
35
+
36
+ // Find the target session
37
+ let session;
38
+ if (options.sessionId) {
39
+ session = allSessions.find((s) => s.id === options.sessionId);
40
+ if (!session) {
41
+ return {
42
+ found: false,
43
+ error: `Session ${options.sessionId} not found. Available: ${allSessions.slice(0, 5).map((s) => s.id).join(", ")}`,
44
+ events: [],
45
+ stats: { total: 0, allowed: 0, warned: 0, blocked: 0 },
46
+ };
47
+ }
48
+ } else {
49
+ // Default to current or most recent
50
+ session = allSessions[0];
51
+ }
52
+
53
+ // Get events for this session
54
+ const sinceTime = session.startedAt;
55
+ const untilTime = session.endedAt || new Date().toISOString();
56
+ const limit = options.limit || 50;
57
+
58
+ let events = readEvents(root, { since: sinceTime });
59
+
60
+ // Filter to events within this session's time window
61
+ if (session.endedAt) {
62
+ events = events.filter((e) => e.at <= session.endedAt);
63
+ }
64
+
65
+ // Reverse to chronological order
66
+ events = events.reverse().slice(0, limit);
67
+
68
+ // Categorize events
69
+ const stats = {
70
+ total: events.length,
71
+ allowed: 0,
72
+ warned: 0,
73
+ blocked: 0,
74
+ changes: 0,
75
+ locks_added: 0,
76
+ locks_removed: 0,
77
+ decisions: 0,
78
+ };
79
+
80
+ const categorized = events.map((evt) => {
81
+ const entry = {
82
+ time: evt.at.substring(11, 19),
83
+ fullTime: evt.at,
84
+ type: evt.type,
85
+ summary: evt.summary || "",
86
+ files: evt.files || [],
87
+ icon: " ",
88
+ level: "INFO",
89
+ };
90
+
91
+ switch (evt.type) {
92
+ case "conflict_blocked":
93
+ entry.icon = "BLOCK";
94
+ entry.level = "BLOCK";
95
+ stats.blocked++;
96
+ break;
97
+ case "conflict_warned":
98
+ entry.icon = "WARN";
99
+ entry.level = "WARN";
100
+ stats.warned++;
101
+ break;
102
+ case "conflict_checked":
103
+ entry.icon = "ALLOW";
104
+ entry.level = "ALLOW";
105
+ stats.allowed++;
106
+ break;
107
+ case "lock_added":
108
+ entry.icon = "LOCK";
109
+ entry.level = "LOCK";
110
+ stats.locks_added++;
111
+ break;
112
+ case "lock_removed":
113
+ entry.icon = "UNLOCK";
114
+ entry.level = "UNLOCK";
115
+ stats.locks_removed++;
116
+ break;
117
+ case "change_logged":
118
+ entry.icon = "CHANGE";
119
+ entry.level = "CHANGE";
120
+ stats.changes++;
121
+ break;
122
+ case "decision_added":
123
+ entry.icon = "DEC";
124
+ entry.level = "DECISION";
125
+ stats.decisions++;
126
+ break;
127
+ case "session_started":
128
+ entry.icon = "START";
129
+ entry.level = "SESSION";
130
+ break;
131
+ case "session_ended":
132
+ entry.icon = "END";
133
+ entry.level = "SESSION";
134
+ break;
135
+ case "revert_detected":
136
+ entry.icon = "REVERT";
137
+ entry.level = "REVERT";
138
+ break;
139
+ case "override_applied":
140
+ entry.icon = "OVERRIDE";
141
+ entry.level = "OVERRIDE";
142
+ break;
143
+ case "context_generated":
144
+ entry.icon = "CTX";
145
+ entry.level = "INFO";
146
+ break;
147
+ default:
148
+ entry.icon = "INFO";
149
+ entry.level = "INFO";
150
+ }
151
+
152
+ return entry;
153
+ });
154
+
155
+ // Duration
156
+ const startMs = new Date(session.startedAt).getTime();
157
+ const endMs = session.endedAt
158
+ ? new Date(session.endedAt).getTime()
159
+ : Date.now();
160
+ const durationMin = Math.round((endMs - startMs) / 60000);
161
+
162
+ return {
163
+ found: true,
164
+ session: {
165
+ id: session.id,
166
+ tool: session.toolUsed,
167
+ startedAt: session.startedAt,
168
+ endedAt: session.endedAt || "(active)",
169
+ duration: `${durationMin} min`,
170
+ summary: session.summary || "(in progress)",
171
+ },
172
+ events: categorized,
173
+ stats,
174
+ };
175
+ }
176
+
177
+ /**
178
+ * List available sessions for replay.
179
+ */
180
+ export function listSessions(root, limit = 10) {
181
+ const brain = ensureInit(root);
182
+ const sessions = [
183
+ ...(brain.sessions.current ? [{ ...brain.sessions.current, isCurrent: true }] : []),
184
+ ...brain.sessions.history.slice(0, limit),
185
+ ];
186
+
187
+ return {
188
+ total: (brain.sessions.history?.length || 0) + (brain.sessions.current ? 1 : 0),
189
+ sessions: sessions.map((s) => ({
190
+ id: s.id,
191
+ tool: s.toolUsed,
192
+ startedAt: s.startedAt,
193
+ endedAt: s.endedAt || "(active)",
194
+ summary: s.summary || "(no summary)",
195
+ events: s.eventsInSession || 0,
196
+ isCurrent: s.isCurrent || false,
197
+ })),
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Format replay data as a readable string for CLI output.
203
+ */
204
+ export function formatReplay(replay) {
205
+ if (!replay.found) {
206
+ return replay.error;
207
+ }
208
+
209
+ const lines = [];
210
+ const s = replay.session;
211
+
212
+ lines.push(`Session: ${s.id} (${s.tool}, ${s.duration})`);
213
+ lines.push(`Started: ${s.startedAt.substring(0, 19).replace("T", " ")}`);
214
+ lines.push(`Ended: ${typeof s.endedAt === "string" && s.endedAt !== "(active)" ? s.endedAt.substring(0, 19).replace("T", " ") : s.endedAt}`);
215
+ lines.push("-".repeat(60));
216
+
217
+ for (const evt of replay.events) {
218
+ const fileStr = evt.files.length > 0 ? ` (${evt.files.slice(0, 3).join(", ")})` : "";
219
+ const pad = evt.icon.length < 6 ? " ".repeat(6 - evt.icon.length) : " ";
220
+ lines.push(`${evt.time} [${evt.icon}]${pad}${evt.summary}${fileStr}`);
221
+ }
222
+
223
+ lines.push("");
224
+ lines.push("-".repeat(60));
225
+ const st = replay.stats;
226
+ const parts = [];
227
+ if (st.allowed > 0) parts.push(`${st.allowed} allowed`);
228
+ if (st.warned > 0) parts.push(`${st.warned} warned`);
229
+ if (st.blocked > 0) parts.push(`${st.blocked} BLOCKED`);
230
+ if (st.changes > 0) parts.push(`${st.changes} changes`);
231
+ if (st.locks_added > 0) parts.push(`${st.locks_added} locks added`);
232
+
233
+ lines.push(`Score: ${st.total} events | ${parts.join(" | ") || "no activity"}`);
234
+
235
+ return lines.join("\n");
236
+ }