@tekyzinc/gsd-t 3.23.10 → 3.24.10
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/CHANGELOG.md +58 -1
- package/bin/gsd-t.js +45 -0
- package/bin/live-activity-report.cjs +615 -0
- package/docs/architecture.md +74 -0
- package/docs/requirements.md +17 -0
- package/package.json +1 -1
- package/scripts/gsd-t-dashboard-server.js +190 -0
- package/scripts/gsd-t-transcript.html +200 -0
- package/scripts/hooks/gsd-t-conversation-capture.js +186 -5
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GSD-T Live Activity Report (M54 D1 T1)
|
|
5
|
+
*
|
|
6
|
+
* Pure read-only observer that answers:
|
|
7
|
+
*
|
|
8
|
+
* "What subprocesses, tool calls, and watches is the orchestrator
|
|
9
|
+
* currently running RIGHT NOW?"
|
|
10
|
+
*
|
|
11
|
+
* Contract: .gsd-t/contracts/live-activity-contract.md v1.0.0
|
|
12
|
+
*
|
|
13
|
+
* Hard rules:
|
|
14
|
+
* 1. NEVER writes a file.
|
|
15
|
+
* 2. NEVER calls an LLM or spawns a subprocess.
|
|
16
|
+
* 3. Silent-fail on malformed inputs — skip the bad line, note it, continue.
|
|
17
|
+
* 4. Zero external deps. `.cjs` so it loads in both ESM and CJS projects.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const os = require('os');
|
|
23
|
+
const crypto = require('crypto');
|
|
24
|
+
|
|
25
|
+
const SCHEMA_VERSION = 1;
|
|
26
|
+
|
|
27
|
+
// Tool-use threshold for `tool` kind detection (30 seconds)
|
|
28
|
+
const TOOL_THRESHOLD_MS = 30_000;
|
|
29
|
+
|
|
30
|
+
// Source-file mtime threshold — files older than this are considered stale (60 seconds)
|
|
31
|
+
const MTIME_STALE_MS = 60_000;
|
|
32
|
+
|
|
33
|
+
// ── Public API ──────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Compute live activities for the given project directory.
|
|
37
|
+
*
|
|
38
|
+
* @param {object} [opts]
|
|
39
|
+
* @param {string} [opts.projectDir='.']
|
|
40
|
+
* @param {Date} [opts.now] injection for tests
|
|
41
|
+
* @returns {{ schemaVersion: number, generatedAt: string, activities: object[], notes: string[] }}
|
|
42
|
+
*/
|
|
43
|
+
function computeLiveActivities(opts) {
|
|
44
|
+
opts = opts || {};
|
|
45
|
+
const projectDir = opts.projectDir || '.';
|
|
46
|
+
const now = opts.now instanceof Date ? opts.now : new Date();
|
|
47
|
+
const notes = [];
|
|
48
|
+
|
|
49
|
+
// Source 1: events JSONL (today only)
|
|
50
|
+
const today = now.toISOString().slice(0, 10);
|
|
51
|
+
const eventsFile = path.join(projectDir, '.gsd-t', 'events', today + '.jsonl');
|
|
52
|
+
const eventsActivities = _readEventsActivities(eventsFile, now, notes);
|
|
53
|
+
const eventsMtime = _safeFileMtime(eventsFile);
|
|
54
|
+
|
|
55
|
+
// Source 2: orchestrator JSONL (~/.claude/projects/<slug>/<sid>.jsonl)
|
|
56
|
+
const orchestratorActivities = _readOrchestratorActivities(projectDir, now, notes);
|
|
57
|
+
|
|
58
|
+
// UNION + dedup
|
|
59
|
+
const allActivities = _dedup([...eventsActivities, ...orchestratorActivities]);
|
|
60
|
+
|
|
61
|
+
// Source 3: spawn plan files (.gsd-t/spawns/*.json)
|
|
62
|
+
const spawnActivities = _readSpawnActivities(projectDir, now, notes);
|
|
63
|
+
const allWithSpawns = _dedup([...allActivities, ...spawnActivities]);
|
|
64
|
+
|
|
65
|
+
// Apply liveness falsifiers
|
|
66
|
+
const live = allWithSpawns.filter((a) => _isLive(a, eventsFile, eventsMtime, now, notes));
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
schemaVersion: SCHEMA_VERSION,
|
|
70
|
+
generatedAt: now.toISOString(),
|
|
71
|
+
activities: live,
|
|
72
|
+
notes,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Internal helpers ────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Read activities from .gsd-t/events/<today>.jsonl.
|
|
80
|
+
* Detects bash (run_in_background) and monitor (Monitor tool_use without stop).
|
|
81
|
+
* Also detects tool kind (any tool_use > 30s without tool_result).
|
|
82
|
+
*/
|
|
83
|
+
function _readEventsActivities(eventsFile, now, notes) {
|
|
84
|
+
const activities = [];
|
|
85
|
+
let content;
|
|
86
|
+
try {
|
|
87
|
+
if (!fs.existsSync(eventsFile)) {
|
|
88
|
+
notes.push('no events file for today');
|
|
89
|
+
return activities;
|
|
90
|
+
}
|
|
91
|
+
content = fs.readFileSync(eventsFile, 'utf8');
|
|
92
|
+
} catch (err) {
|
|
93
|
+
notes.push('could not read events file: ' + (err && err.message || String(err)));
|
|
94
|
+
return activities;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const lines = content.split(/\r?\n/);
|
|
98
|
+
const toolUseById = new Map(); // tool_use_id -> event object
|
|
99
|
+
const terminatedIds = new Set(); // tool_use_ids with tool_result or monitor_stopped
|
|
100
|
+
const monitorStoppedIds = new Set();
|
|
101
|
+
|
|
102
|
+
// First pass: collect all events
|
|
103
|
+
const allEvents = [];
|
|
104
|
+
for (let i = 0; i < lines.length; i++) {
|
|
105
|
+
const line = lines[i].trim();
|
|
106
|
+
if (!line) continue;
|
|
107
|
+
let obj;
|
|
108
|
+
try {
|
|
109
|
+
obj = JSON.parse(line);
|
|
110
|
+
} catch (_) {
|
|
111
|
+
notes.push('skipped malformed JSONL line at ' + eventsFile + ':' + (i + 1));
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (!obj || typeof obj !== 'object') continue;
|
|
115
|
+
allEvents.push({ obj, lineno: i + 1 });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Second pass: build lookup maps
|
|
119
|
+
for (const { obj } of allEvents) {
|
|
120
|
+
const type = obj.type || obj.event_type;
|
|
121
|
+
|
|
122
|
+
// tool_result terminates a tool_use
|
|
123
|
+
if (type === 'tool_result' && obj.tool_use_id) {
|
|
124
|
+
terminatedIds.add(obj.tool_use_id);
|
|
125
|
+
}
|
|
126
|
+
// monitor_stopped terminates a monitor
|
|
127
|
+
if (type === 'monitor_stopped' && obj.tool_use_id) {
|
|
128
|
+
terminatedIds.add(obj.tool_use_id);
|
|
129
|
+
monitorStoppedIds.add(obj.tool_use_id);
|
|
130
|
+
}
|
|
131
|
+
// spawn_completed terminates a spawn
|
|
132
|
+
if (type === 'spawn_completed' && obj.tool_use_id) {
|
|
133
|
+
terminatedIds.add(obj.tool_use_id);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Third pass: detect activities
|
|
138
|
+
for (const { obj } of allEvents) {
|
|
139
|
+
const type = obj.type || obj.event_type;
|
|
140
|
+
|
|
141
|
+
// bash: run_in_background sentinel
|
|
142
|
+
if (obj.run_in_background === true) {
|
|
143
|
+
const id = obj.tool_use_id || _makeId('bash', obj.command || obj.label || '', obj.startedAt || obj.ts || now.toISOString());
|
|
144
|
+
if (!terminatedIds.has(id)) {
|
|
145
|
+
activities.push(_makeActivity({
|
|
146
|
+
id,
|
|
147
|
+
kind: 'bash',
|
|
148
|
+
label: obj.command || obj.label || 'bash',
|
|
149
|
+
startedAt: obj.startedAt || obj.ts || now.toISOString(),
|
|
150
|
+
now,
|
|
151
|
+
pid: obj.pid,
|
|
152
|
+
toolUseId: obj.tool_use_id,
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Monitor and tool: tool_use events
|
|
159
|
+
const toolName = obj.name || (obj.type === 'tool_use' && obj.name) || (obj.event_type === 'tool_use_started' && obj.name);
|
|
160
|
+
if (type === 'tool_use' || type === 'tool_use_started') {
|
|
161
|
+
const toolUseId = obj.tool_use_id || obj.id;
|
|
162
|
+
if (!toolUseId) continue;
|
|
163
|
+
if (terminatedIds.has(toolUseId)) continue;
|
|
164
|
+
|
|
165
|
+
const name = obj.name || toolName || '';
|
|
166
|
+
const startedAt = obj.startedAt || obj.start_time || obj.ts || now.toISOString();
|
|
167
|
+
const startMs = _safeDate(startedAt);
|
|
168
|
+
const ageMs = startMs ? now.getTime() - startMs.getTime() : 0;
|
|
169
|
+
|
|
170
|
+
if (name === 'Monitor') {
|
|
171
|
+
// Monitor kind
|
|
172
|
+
activities.push(_makeActivity({
|
|
173
|
+
id: toolUseId,
|
|
174
|
+
kind: 'monitor',
|
|
175
|
+
label: (obj.input && (obj.input.command || obj.input.path)) || name,
|
|
176
|
+
startedAt,
|
|
177
|
+
now,
|
|
178
|
+
pid: obj.pid,
|
|
179
|
+
toolUseId,
|
|
180
|
+
}));
|
|
181
|
+
} else if (ageMs > TOOL_THRESHOLD_MS) {
|
|
182
|
+
// Tool kind: any tool_use > 30s without result
|
|
183
|
+
activities.push(_makeActivity({
|
|
184
|
+
id: toolUseId,
|
|
185
|
+
kind: 'tool',
|
|
186
|
+
label: name || 'tool',
|
|
187
|
+
startedAt,
|
|
188
|
+
now,
|
|
189
|
+
pid: obj.pid,
|
|
190
|
+
toolUseId,
|
|
191
|
+
}));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return activities;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Read activities from the orchestrator JSONL (~/.claude/projects/<slug>/<sid>.jsonl).
|
|
201
|
+
* Uses slug discovery via _slugFromTranscriptPath / _slugToProjectDir from
|
|
202
|
+
* scripts/hooks/gsd-t-conversation-capture.js.
|
|
203
|
+
*/
|
|
204
|
+
function _readOrchestratorActivities(projectDir, now, notes) {
|
|
205
|
+
const activities = [];
|
|
206
|
+
|
|
207
|
+
// Lazy-load the slug helpers from conversation-capture.js
|
|
208
|
+
let slugHelpers;
|
|
209
|
+
try {
|
|
210
|
+
slugHelpers = require(path.join(__dirname, '..', 'scripts', 'hooks', 'gsd-t-conversation-capture.js'));
|
|
211
|
+
} catch (err) {
|
|
212
|
+
notes.push('could not load slug helpers: ' + (err && err.message || String(err)));
|
|
213
|
+
return activities;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const { _slugFromTranscriptPath, _slugToProjectDir } = slugHelpers._internal || slugHelpers;
|
|
217
|
+
|
|
218
|
+
// Derive slug from project dir: reverse the _slugToProjectDir logic
|
|
219
|
+
// by looking for a slug in ~/.claude/projects/ that decodes to projectDir
|
|
220
|
+
const home = process.env.HOME || os.homedir();
|
|
221
|
+
if (!home) {
|
|
222
|
+
notes.push('could not determine HOME directory for slug discovery');
|
|
223
|
+
return activities;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const projectsDir = path.join(home, '.claude', 'projects');
|
|
227
|
+
let slugDir;
|
|
228
|
+
try {
|
|
229
|
+
if (!fs.existsSync(projectsDir)) {
|
|
230
|
+
notes.push('no ~/.claude/projects/ directory found');
|
|
231
|
+
return activities;
|
|
232
|
+
}
|
|
233
|
+
// Enumerate slugs and find one that maps to projectDir
|
|
234
|
+
const slugs = fs.readdirSync(projectsDir, { withFileTypes: true })
|
|
235
|
+
.filter((e) => e.isDirectory())
|
|
236
|
+
.map((e) => e.name);
|
|
237
|
+
|
|
238
|
+
const resolvedProject = path.resolve(projectDir);
|
|
239
|
+
for (const slug of slugs) {
|
|
240
|
+
const decoded = _slugToProjectDir(slug);
|
|
241
|
+
if (decoded && path.resolve(decoded) === resolvedProject) {
|
|
242
|
+
slugDir = path.join(projectsDir, slug);
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
} catch (err) {
|
|
247
|
+
notes.push('slug discovery error: ' + (err && err.message || String(err)));
|
|
248
|
+
return activities;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (!slugDir) {
|
|
252
|
+
notes.push('orchestrator slug unresolvable for cwd=' + projectDir);
|
|
253
|
+
return activities;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Find most recent JSONL file in the slug directory
|
|
257
|
+
let jsonlFile;
|
|
258
|
+
try {
|
|
259
|
+
const files = fs.readdirSync(slugDir)
|
|
260
|
+
.filter((f) => f.endsWith('.jsonl'))
|
|
261
|
+
.map((f) => {
|
|
262
|
+
const full = path.join(slugDir, f);
|
|
263
|
+
let mtime = 0;
|
|
264
|
+
try { mtime = fs.statSync(full).mtimeMs; } catch (_) { /* noop */ }
|
|
265
|
+
return { f, full, mtime };
|
|
266
|
+
})
|
|
267
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
268
|
+
if (files.length > 0) jsonlFile = files[0].full;
|
|
269
|
+
} catch (err) {
|
|
270
|
+
notes.push('could not list slug dir ' + slugDir + ': ' + (err && err.message || String(err)));
|
|
271
|
+
return activities;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (!jsonlFile) {
|
|
275
|
+
notes.push('no JSONL files in slug dir ' + slugDir);
|
|
276
|
+
return activities;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Parse orchestrator JSONL
|
|
280
|
+
let content;
|
|
281
|
+
try {
|
|
282
|
+
content = fs.readFileSync(jsonlFile, 'utf8');
|
|
283
|
+
} catch (err) {
|
|
284
|
+
notes.push('could not read orchestrator JSONL ' + jsonlFile + ': ' + (err && err.message || String(err)));
|
|
285
|
+
return activities;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const lines = content.split(/\r?\n/);
|
|
289
|
+
const terminatedIds = new Set();
|
|
290
|
+
const allEvents = [];
|
|
291
|
+
|
|
292
|
+
// First pass: collect
|
|
293
|
+
for (let i = 0; i < lines.length; i++) {
|
|
294
|
+
const line = lines[i].trim();
|
|
295
|
+
if (!line) continue;
|
|
296
|
+
let obj;
|
|
297
|
+
try {
|
|
298
|
+
obj = JSON.parse(line);
|
|
299
|
+
} catch (_) {
|
|
300
|
+
// Orchestrator JSONL lines can be complex; skip silently
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (!obj || typeof obj !== 'object') continue;
|
|
304
|
+
allEvents.push(obj);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// The orchestrator JSONL stores conversation turns. Each line is an object with
|
|
308
|
+
// a `message` field containing a Claude API message (role: "assistant" | "user").
|
|
309
|
+
// Tool uses are in assistant messages: message.content[].type === "tool_use".
|
|
310
|
+
// Tool results are in user messages: message.content[].type === "tool_result".
|
|
311
|
+
|
|
312
|
+
// First pass: collect all termination signals from tool_result blocks
|
|
313
|
+
for (const obj of allEvents) {
|
|
314
|
+
// Direct termination events (from GSD-T event hooks)
|
|
315
|
+
if (obj.type === 'monitor_stopped' && obj.tool_use_id) {
|
|
316
|
+
terminatedIds.add(obj.tool_use_id);
|
|
317
|
+
}
|
|
318
|
+
if (obj.type === 'spawn_completed' && obj.tool_use_id) {
|
|
319
|
+
terminatedIds.add(obj.tool_use_id);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Claude API user turns: message.content[].type === "tool_result"
|
|
323
|
+
const msg = obj.message;
|
|
324
|
+
if (msg && msg.role === 'user' && Array.isArray(msg.content)) {
|
|
325
|
+
for (const block of msg.content) {
|
|
326
|
+
if (block && block.type === 'tool_result' && block.tool_use_id) {
|
|
327
|
+
terminatedIds.add(block.tool_use_id);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Second pass: collect tool_use blocks from assistant messages
|
|
334
|
+
const toolUseBlocks = []; // { block, timestamp }
|
|
335
|
+
for (const obj of allEvents) {
|
|
336
|
+
const msg = obj.message;
|
|
337
|
+
if (!msg || msg.role !== 'assistant' || !Array.isArray(msg.content)) continue;
|
|
338
|
+
// Timestamp: prefer the message-level timestamp if available
|
|
339
|
+
const timestamp = obj.timestamp || (msg && msg.created_at) || null;
|
|
340
|
+
for (const block of msg.content) {
|
|
341
|
+
if (block && block.type === 'tool_use' && block.id) {
|
|
342
|
+
toolUseBlocks.push({ block, timestamp });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Third pass: emit activities for non-terminated tool uses
|
|
348
|
+
for (const { block, timestamp } of toolUseBlocks) {
|
|
349
|
+
const toolUseId = block.id;
|
|
350
|
+
if (terminatedIds.has(toolUseId)) continue;
|
|
351
|
+
|
|
352
|
+
const name = block.name || '';
|
|
353
|
+
const startedAt = timestamp || now.toISOString();
|
|
354
|
+
const startMs = _safeDate(startedAt);
|
|
355
|
+
const ageMs = startMs ? now.getTime() - startMs.getTime() : 0;
|
|
356
|
+
|
|
357
|
+
if (name === 'Bash') {
|
|
358
|
+
const isBg = block.input && block.input.run_in_background === true;
|
|
359
|
+
if (isBg) {
|
|
360
|
+
const cmd = (block.input && block.input.command) || name;
|
|
361
|
+
activities.push(_makeActivity({
|
|
362
|
+
id: toolUseId,
|
|
363
|
+
kind: 'bash',
|
|
364
|
+
label: cmd,
|
|
365
|
+
startedAt,
|
|
366
|
+
now,
|
|
367
|
+
toolUseId,
|
|
368
|
+
}));
|
|
369
|
+
}
|
|
370
|
+
} else if (name === 'Monitor') {
|
|
371
|
+
activities.push(_makeActivity({
|
|
372
|
+
id: toolUseId,
|
|
373
|
+
kind: 'monitor',
|
|
374
|
+
label: (block.input && (block.input.command || block.input.path)) || name,
|
|
375
|
+
startedAt,
|
|
376
|
+
now,
|
|
377
|
+
toolUseId,
|
|
378
|
+
}));
|
|
379
|
+
} else if (ageMs > TOOL_THRESHOLD_MS) {
|
|
380
|
+
activities.push(_makeActivity({
|
|
381
|
+
id: toolUseId,
|
|
382
|
+
kind: 'tool',
|
|
383
|
+
label: name || 'tool',
|
|
384
|
+
startedAt,
|
|
385
|
+
now,
|
|
386
|
+
toolUseId,
|
|
387
|
+
}));
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return activities;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Read spawn kind activities from .gsd-t/spawns/*.json plan files.
|
|
396
|
+
* Delegates to the same plan-file reader shape as parallelism-report.cjs.
|
|
397
|
+
*/
|
|
398
|
+
function _readSpawnActivities(projectDir, now, notes) {
|
|
399
|
+
const activities = [];
|
|
400
|
+
const spawnPlans = _readSpawnPlans(projectDir, notes);
|
|
401
|
+
|
|
402
|
+
for (const plan of spawnPlans) {
|
|
403
|
+
// Only include active (not ended) plans
|
|
404
|
+
if (plan.endedAt !== null && plan.endedAt !== undefined) continue;
|
|
405
|
+
|
|
406
|
+
const startedAt = plan.startedAt || now.toISOString();
|
|
407
|
+
const label = (plan.spawnId || plan.kind || 'spawn') + (plan.command ? ' ' + String(plan.command).slice(0, 30) : '');
|
|
408
|
+
const id = plan.spawnId || _makeId('spawn', label, startedAt);
|
|
409
|
+
|
|
410
|
+
activities.push(_makeActivity({
|
|
411
|
+
id,
|
|
412
|
+
kind: 'spawn',
|
|
413
|
+
label,
|
|
414
|
+
startedAt,
|
|
415
|
+
now,
|
|
416
|
+
pid: plan.pid || plan.workerPid,
|
|
417
|
+
toolUseId: plan.tool_use_id,
|
|
418
|
+
}));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return activities;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Read .gsd-t/spawns/*.json plan files. Mirrors parallelism-report.cjs's _readSpawnPlans.
|
|
426
|
+
*/
|
|
427
|
+
function _readSpawnPlans(projectDir, notes) {
|
|
428
|
+
const dir = path.join(projectDir, '.gsd-t', 'spawns');
|
|
429
|
+
let files;
|
|
430
|
+
try {
|
|
431
|
+
if (!fs.existsSync(dir)) return [];
|
|
432
|
+
files = fs.readdirSync(dir).filter((n) => n.endsWith('.json'));
|
|
433
|
+
} catch (err) {
|
|
434
|
+
notes.push('spawns dir unreadable: ' + (err && err.message || err));
|
|
435
|
+
return [];
|
|
436
|
+
}
|
|
437
|
+
const plans = [];
|
|
438
|
+
for (const f of files) {
|
|
439
|
+
const full = path.join(dir, f);
|
|
440
|
+
let raw;
|
|
441
|
+
try {
|
|
442
|
+
raw = fs.readFileSync(full, 'utf8');
|
|
443
|
+
} catch (err) {
|
|
444
|
+
notes.push('could not read spawn plan ' + full + ': ' + (err && err.message || err));
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
let parsed;
|
|
448
|
+
try {
|
|
449
|
+
parsed = JSON.parse(raw);
|
|
450
|
+
} catch (_) {
|
|
451
|
+
notes.push('spawn-plan malformed JSON: ' + f);
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
455
|
+
notes.push('spawn-plan not an object: ' + f);
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
plans.push(parsed);
|
|
459
|
+
}
|
|
460
|
+
return plans;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Deduplicate activities by tool_use_id (priority 1) then (kind, label, startedAt) tuple (priority 2).
|
|
465
|
+
*/
|
|
466
|
+
function _dedup(activities) {
|
|
467
|
+
const byToolUseId = new Map();
|
|
468
|
+
const byTuple = new Map();
|
|
469
|
+
const result = [];
|
|
470
|
+
|
|
471
|
+
for (const a of activities) {
|
|
472
|
+
// Priority 1: deduplicate by tool_use_id
|
|
473
|
+
if (a.toolUseId) {
|
|
474
|
+
if (byToolUseId.has(a.toolUseId)) {
|
|
475
|
+
// Merge: prefer orchestrator pid when present
|
|
476
|
+
const existing = byToolUseId.get(a.toolUseId);
|
|
477
|
+
if (a.pid && !existing.pid) existing.pid = a.pid;
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
byToolUseId.set(a.toolUseId, a);
|
|
481
|
+
result.push(a);
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Priority 2: deduplicate by (kind, label, startedAt) tuple
|
|
486
|
+
const tupleKey = a.kind + '|' + a.label + '|' + a.startedAt;
|
|
487
|
+
if (byTuple.has(tupleKey)) {
|
|
488
|
+
const existing = byTuple.get(tupleKey);
|
|
489
|
+
if (a.pid && !existing.pid) existing.pid = a.pid;
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
byTuple.set(tupleKey, a);
|
|
493
|
+
result.push(a);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return result;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Apply all 3 liveness falsifiers to an activity.
|
|
501
|
+
* Returns true if the activity should remain in activities[].
|
|
502
|
+
* Returns false if any falsifier fires.
|
|
503
|
+
*/
|
|
504
|
+
function _isLive(activity, eventsFile, eventsMtime, now, notes) {
|
|
505
|
+
// F1: explicit terminating event (already handled during parsing — entries with
|
|
506
|
+
// matching tool_result/monitor_stopped/spawn_completed were never added)
|
|
507
|
+
// This is a double-check for spawn kind which reads from a different source.
|
|
508
|
+
if (activity.kind === 'spawn') {
|
|
509
|
+
// For spawn kind, F1 is already handled in _readSpawnActivities (endedAt check).
|
|
510
|
+
// No additional F1 check needed here for spawns.
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// F2: PID check — process.kill(pid, 0) throws ESRCH
|
|
514
|
+
if (activity.pid) {
|
|
515
|
+
try {
|
|
516
|
+
process.kill(activity.pid, 0);
|
|
517
|
+
// Process is alive (no error thrown)
|
|
518
|
+
} catch (err) {
|
|
519
|
+
if (err && err.code === 'ESRCH') {
|
|
520
|
+
// Process not found — remove entry
|
|
521
|
+
return false;
|
|
522
|
+
}
|
|
523
|
+
// EPERM or other: conservative, treat as dead
|
|
524
|
+
notes.push('PID check error for pid ' + activity.pid + ': ' + (err && err.code || String(err)));
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// F3: Source-file mtime > 60s old
|
|
530
|
+
if (activity.kind === 'spawn') {
|
|
531
|
+
// Spawn activities use spawn plan file mtime, not events file
|
|
532
|
+
// Spawn plan mtime > 60s → stale
|
|
533
|
+
const spawnMtime = activity._spawnPlanMtime;
|
|
534
|
+
if (spawnMtime) {
|
|
535
|
+
const ageMs = now.getTime() - spawnMtime;
|
|
536
|
+
if (ageMs > MTIME_STALE_MS) return false;
|
|
537
|
+
}
|
|
538
|
+
} else {
|
|
539
|
+
// Events-sourced activities: check events file mtime
|
|
540
|
+
if (eventsMtime) {
|
|
541
|
+
const ageMs = now.getTime() - eventsMtime;
|
|
542
|
+
if (ageMs > MTIME_STALE_MS) return false;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return true;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Safely stat a file and return its mtime in milliseconds, or null on error.
|
|
551
|
+
*/
|
|
552
|
+
function _safeFileMtime(filePath) {
|
|
553
|
+
try {
|
|
554
|
+
return fs.statSync(filePath).mtimeMs;
|
|
555
|
+
} catch (_) {
|
|
556
|
+
return null;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Construct an Activity object.
|
|
562
|
+
*/
|
|
563
|
+
function _makeActivity({ id, kind, label, startedAt, now, pid, toolUseId, _spawnPlanMtime }) {
|
|
564
|
+
const startMs = _safeDate(startedAt);
|
|
565
|
+
const durationMs = startMs ? Math.max(0, now.getTime() - startMs.getTime()) : 0;
|
|
566
|
+
const activity = {
|
|
567
|
+
id: String(id),
|
|
568
|
+
kind,
|
|
569
|
+
label: String(label),
|
|
570
|
+
startedAt: startMs ? startMs.toISOString() : new Date(now).toISOString(),
|
|
571
|
+
durationMs,
|
|
572
|
+
tailUrl: '/api/live-activity/' + encodeURIComponent(String(id)) + '/tail',
|
|
573
|
+
alive: true,
|
|
574
|
+
};
|
|
575
|
+
if (pid != null) activity.pid = pid;
|
|
576
|
+
if (toolUseId) activity.toolUseId = toolUseId;
|
|
577
|
+
if (_spawnPlanMtime != null) activity._spawnPlanMtime = _spawnPlanMtime;
|
|
578
|
+
return activity;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Generate a deterministic ID from kind + label + startedAt when no tool_use_id is available.
|
|
583
|
+
*/
|
|
584
|
+
function _makeId(kind, label, startedAt) {
|
|
585
|
+
const hash = crypto.createHash('sha1').update(label + startedAt).digest('hex').slice(0, 12);
|
|
586
|
+
return kind + ':' + hash;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Safely parse a date string. Returns null for invalid inputs.
|
|
591
|
+
*/
|
|
592
|
+
function _safeDate(v) {
|
|
593
|
+
if (!v || typeof v !== 'string') return null;
|
|
594
|
+
const d = new Date(v);
|
|
595
|
+
if (isNaN(d.getTime())) return null;
|
|
596
|
+
return d;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// ── Module exports ──────────────────────────────────────────────────────────
|
|
600
|
+
|
|
601
|
+
module.exports = {
|
|
602
|
+
computeLiveActivities,
|
|
603
|
+
SCHEMA_VERSION,
|
|
604
|
+
// Exposed for unit tests only; not part of the public contract.
|
|
605
|
+
_readEventsActivities,
|
|
606
|
+
_readOrchestratorActivities,
|
|
607
|
+
_readSpawnActivities,
|
|
608
|
+
_readSpawnPlans,
|
|
609
|
+
_dedup,
|
|
610
|
+
_isLive,
|
|
611
|
+
_makeActivity,
|
|
612
|
+
_makeId,
|
|
613
|
+
_safeDate,
|
|
614
|
+
_safeFileMtime,
|
|
615
|
+
};
|
package/docs/architecture.md
CHANGED
|
@@ -1008,6 +1008,15 @@ defined in `.gsd-t/contracts/parallelism-report-contract.md` v1.0.0.
|
|
|
1008
1008
|
Per-spawn timeline, Per-gate decisions, Per-worker Gantt, Token cost, and
|
|
1009
1009
|
Notes sections.
|
|
1010
1010
|
|
|
1011
|
+
**Install location**: the dashboard server (installed at
|
|
1012
|
+
`~/.claude/scripts/gsd-t-dashboard-server.js`) resolves
|
|
1013
|
+
`require(path.join(__dirname, "..", "bin", "parallelism-report.cjs"))` at
|
|
1014
|
+
request time, so the module must live at **`~/.claude/bin/parallelism-report.cjs`**.
|
|
1015
|
+
The installer handles this via `installGlobalBinTools()` (driven by
|
|
1016
|
+
`GLOBAL_BIN_TOOLS` in `bin/gsd-t.js`), and `gsd-t doctor` flags any missing
|
|
1017
|
+
entry. This is distinct from `PROJECT_BIN_TOOLS`, which copies into each
|
|
1018
|
+
registered project's local `bin/`.
|
|
1019
|
+
|
|
1011
1020
|
**Data flow**:
|
|
1012
1021
|
|
|
1013
1022
|
```
|
|
@@ -1117,3 +1126,68 @@ A new Red Team category — "Test Pass-Through — Journey Edition" — extends
|
|
|
1117
1126
|
|
|
1118
1127
|
Contract: `.gsd-t/contracts/journey-coverage-contract.md` v1.0.0.
|
|
1119
1128
|
|
|
1129
|
+
## Live Activity Observability (M54 — v3.24.10)
|
|
1130
|
+
|
|
1131
|
+
The dashboard left rail today only catches detached `claude -p` workers via `.gsd-t/spawns/*.json`. Heavy in-session work (a backgrounded `Bash`, a `Monitor` watch, a slow tool_use) has no rail representation, leaving the user blind to what's running. M54 adds a "LIVE ACTIVITY" section between MAIN SESSION and LIVE SPAWNS that surfaces all four kinds in one place.
|
|
1132
|
+
|
|
1133
|
+
**Module**: `bin/live-activity-report.cjs` — `computeLiveActivities({projectDir, now?})` returns the contract shape. Pure read-only, silent-fail. Mirrors `bin/parallelism-report.cjs` shape; installed via `installGlobalBinTools()` to `~/.claude/bin/live-activity-report.cjs`.
|
|
1134
|
+
|
|
1135
|
+
**Data flow**:
|
|
1136
|
+
|
|
1137
|
+
```
|
|
1138
|
+
.gsd-t/events/*.jsonl ─┐ (heartbeat-emitted tool events)
|
|
1139
|
+
~/.claude/projects/<slug>/<sid>.jsonl ┼─▶ bin/live-activity-report.cjs
|
|
1140
|
+
.gsd-t/spawns/*.json ─┘ │ (dedupe by tool_use_id)
|
|
1141
|
+
▼
|
|
1142
|
+
scripts/gsd-t-dashboard-server.js
|
|
1143
|
+
(5s in-memory cache, additive endpoints)
|
|
1144
|
+
├── GET /api/live-activity → JSON index
|
|
1145
|
+
├── GET /api/live-activity/<id>/tail → last 64 KB stdout/stderr
|
|
1146
|
+
└── GET /api/live-activity/<id>/stream → SSE follow-up
|
|
1147
|
+
│
|
|
1148
|
+
▼
|
|
1149
|
+
scripts/gsd-t-transcript.html
|
|
1150
|
+
`<aside class="left-rail">` — new section "LIVE ACTIVITY"
|
|
1151
|
+
polls /api/live-activity every 5s; pulses new entries
|
|
1152
|
+
```
|
|
1153
|
+
|
|
1154
|
+
**Liveness falsifiers** (any → entry leaves activities[]):
|
|
1155
|
+
1. Explicit terminating event (tool_result, monitor_stopped, spawn_completed).
|
|
1156
|
+
2. PID check fails (`process.kill(pid, 0)` throws ESRCH) for kinds with a recorded PID.
|
|
1157
|
+
3. Source file mtime > 60s old.
|
|
1158
|
+
|
|
1159
|
+
**Pulse semantics**: new entries get class `.la-pulsing`. Pulse stops on click OR on liveness loss OR after 30s — whichever first. No auto-switch of the bottom pane on entry arrival; only the pulse signals attention.
|
|
1160
|
+
|
|
1161
|
+
**Silent-fail invariant**: malformed events JSONL, missing slug-decoded transcript, unreadable spawn-plan JSON — every case logs to `metrics.notes` and continues with partial data. Observer must never throw when watching a live system.
|
|
1162
|
+
|
|
1163
|
+
**Out of scope**: cross-project aggregation (read OTHER projects' transcripts) is M55 candidate territory.
|
|
1164
|
+
|
|
1165
|
+
Contract: `.gsd-t/contracts/live-activity-contract.md` v1.0.0 STABLE (D1 complete 2026-05-07).
|
|
1166
|
+
|
|
1167
|
+
**Endpoint signatures** (D1 implemented):
|
|
1168
|
+
- `GET /api/live-activity` → `handleLiveActivity(req, res, projectDir)` — 5s cache, `Cache-Control: no-store`, returns JSON envelope `{schemaVersion:1, activities:[], notes:[]}`
|
|
1169
|
+
- `GET /api/live-activity/<id>/tail` → `handleLiveActivityTail(req, res, projectDir, id)` — 5s per-id cache, path-traversal guard via `isValidActivityId`, 400 on invalid id, 404 on unknown id
|
|
1170
|
+
- `GET /api/live-activity/<id>/stream` → `handleLiveActivityStream(req, res, projectDir, id)` — SSE, uncached, 15s heartbeat, closes when activity removed
|
|
1171
|
+
|
|
1172
|
+
**Install path**: `~/.claude/bin/live-activity-report.cjs` (via `GLOBAL_BIN_TOOLS` in `bin/gsd-t.js`, installed by `installGlobalBinTools()`).
|
|
1173
|
+
|
|
1174
|
+
**D2 rail section** (`scripts/gsd-t-transcript.html`, D2 complete 2026-05-07):
|
|
1175
|
+
|
|
1176
|
+
The viewer's left rail gains a new `<section id="rail-live-activity">` inserted between MAIN SESSION and LIVE SPAWNS. The section is purely additive — no existing sections were modified.
|
|
1177
|
+
|
|
1178
|
+
Rail behavior:
|
|
1179
|
+
- 5s polling interval via `setInterval` (`wireLiveActivity()` IIFE in inline `<script>`)
|
|
1180
|
+
- On each poll, `reconcile(activities)` diffs the DOM against the API response:
|
|
1181
|
+
- New entries: `appendActivity(entry)` creates `.la-entry` with dot + icon + 40-char label + duration counter; applies `.la-pulsing`
|
|
1182
|
+
- Removed entries: `removeActivity(id)` removes element from DOM
|
|
1183
|
+
- Existing entries: `updateDuration(id, startedAt)` refreshes the wall-clock counter
|
|
1184
|
+
- Pulse stops on: (a) click, (b) entry absent in next response, (c) 30s elapsed (`setTimeout`)
|
|
1185
|
+
- Click handler: `stopPulse(id)` + `loadTailUrl(tailUrl)` — loads tail content into the bottom pane; NO auto-switch on entry arrival
|
|
1186
|
+
- Error tolerance: fetch 500 or network error → log once, empty section header, no crash
|
|
1187
|
+
|
|
1188
|
+
CSS additions: `@keyframes accent-pulse` (~1.5s), `.la-pulsing`, `.la-dot-running` (teal), `.la-dot-stale` (dimmed), `.la-icon-{bash,monitor,tool,spawn}`, `.la-label` (40-char truncated).
|
|
1189
|
+
|
|
1190
|
+
**Executable attestation**: 2 live-journey specs in `e2e/live-journeys/`:
|
|
1191
|
+
- `live-activity.spec.ts`: single bash, asserts 5s appearance, pulse, duration tick, click→tail, kill→5s disappearance; self-skips if no dashboard
|
|
1192
|
+
- `live-activity-multikind.spec.ts`: 3 concurrent kinds (bash + monitor + tool) via synthetic events JSONL, asserts dedup correctness; self-skips if no dashboard
|
|
1193
|
+
|