@dadado/agent-kit-cli 4.8.0 → 4.8.2

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,813 @@
1
+ #!/usr/bin/env node
2
+ // dashboard/dashboard-data.mjs
3
+ // Data fetcher for Startup Kit Dashboard
4
+ // Scans .cursor/plans, HANDOFF, memory, config, git status, terminals, processes
5
+ // Outputs JSON to stdout (consumed by dashboard.html)
6
+
7
+ import { execSync } from "node:child_process";
8
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
9
+ import { dirname, join, resolve } from "node:path";
10
+ import {
11
+ MAX_STRING,
12
+ allowlistConfig,
13
+ parseGitStatusShort,
14
+ resolveSnapshotRepoRoot,
15
+ truncateStr,
16
+ } from "./lib/guards.mjs";
17
+ import {
18
+ EXTERNAL_REPORT_FILE_RE,
19
+ FIELD_REPORT_CADENCE_LEDGER_REL,
20
+ FLIGHT_LOG_LEDGER_REL,
21
+ MAX_AGENT_PROMPTS,
22
+ MAX_GIT_ACTIVITY,
23
+ MISSION_TIMING_LEDGER_REL,
24
+ buildMissionControlView,
25
+ collectDeferredCheckIds,
26
+ collectReadinessPendingFromReport,
27
+ detectAwaitingPrompt,
28
+ dismissedAttentionIds,
29
+ extractChatSnippet,
30
+ parseCadenceLedger,
31
+ parseExternalReport,
32
+ parseFieldReportDismissals,
33
+ parseFieldReportReviewCadenceConfig,
34
+ parseFlightLogLedger,
35
+ parseHandoffMarkdown,
36
+ parseMissionTimingLedger,
37
+ serializeFlightLogLedger,
38
+ serializeMissionTimingLedger,
39
+ } from "./lib/semantic-model.mjs";
40
+
41
+ const KIT_ROOT = resolve(import.meta.dirname, "..");
42
+ /** Snapshot root: consumer workspace when MISSION_CONTROL_REPO_ROOT is set, else kit tree. */
43
+ const ROOT = resolveSnapshotRepoRoot(process.env, KIT_ROOT);
44
+ const MAX_TERMINALS = 20;
45
+ const MAX_PROCESSES = 25;
46
+ const MAX_TERMINAL_BYTES = 64 * 1024;
47
+ const MAX_LAST_OUTPUT_LINES = 15;
48
+ const MAX_LAST_OUTPUT_CHARS = 1200;
49
+
50
+ // Agent-prompt scan bounds (fs half of the detection contract in semantic-model.mjs).
51
+ const MAX_TRANSCRIPT_FILES = 60; // cap directory reads per snapshot
52
+ const MAX_TRANSCRIPT_BYTES = 1024 * 1024; // skip oversized transcripts, degrade quietly
53
+ const TRANSCRIPT_RECENCY_MS = 30 * 24 * 60 * 60 * 1000; // 30-day recency window
54
+
55
+ // External review report scan bounds (fs half of the triage contract).
56
+ const MAX_REPORT_FILES = 20; // cap memory reads per snapshot
57
+ const MAX_REPORT_BYTES = 512 * 1024; // skip oversized reports, degrade quietly
58
+ const REPORT_RECENCY_MS = 90 * 24 * 60 * 60 * 1000; // 90-day recency window
59
+
60
+ /** Redact likely secrets in terminal output (paths-only git payload uses separate rules). */
61
+ const SECRET_OUTPUT_PATTERNS = [
62
+ /(?:API_KEY|SECRET|PASSWORD|TOKEN|PRIVATE_KEY)\s*=\s*\S+/gi,
63
+ /(?:api[_-]?key|secret|password|token|authorization)\s*[:=]\s*\S+/gi,
64
+ ];
65
+
66
+ function redactTerminalMeta(meta) {
67
+ const out = { ...meta };
68
+ if (out.cwd) out.cwd = truncateStr(out.cwd, MAX_STRING.terminalCwd);
69
+ if (out.lastCommand) out.lastCommand = truncateStr(out.lastCommand, MAX_STRING.terminalCommand);
70
+ return out;
71
+ }
72
+
73
+ function redactTerminalOutput(text) {
74
+ let out = String(text);
75
+ for (const pat of SECRET_OUTPUT_PATTERNS) {
76
+ out = out.replace(pat, (match) => {
77
+ const sep = match.includes("=") ? "=" : ":";
78
+ const key = match.split(sep)[0];
79
+ return `${key}${sep}***`;
80
+ });
81
+ }
82
+ return out;
83
+ }
84
+
85
+ /** Last N lines of terminal body after YAML header, char-capped and redacted. */
86
+ function extractLastOutput(rawContent) {
87
+ const lines = rawContent.split("\n");
88
+ let headerEnd = 0;
89
+ let dashCount = 0;
90
+ for (let i = 0; i < lines.length; i++) {
91
+ if (lines[i].trim() === "---") {
92
+ dashCount++;
93
+ if (dashCount === 2) {
94
+ headerEnd = i + 1;
95
+ break;
96
+ }
97
+ }
98
+ }
99
+ if (headerEnd === 0) headerEnd = 10;
100
+
101
+ const bodyLines = lines.slice(headerEnd).filter((l) => l.trim() && !l.startsWith("---"));
102
+ if (bodyLines.length === 0) return null;
103
+
104
+ const tail = bodyLines.slice(-MAX_LAST_OUTPUT_LINES);
105
+ let text = redactTerminalOutput(tail.join("\n"));
106
+ text = truncateStr(text, MAX_LAST_OUTPUT_CHARS);
107
+ return text?.trim() ? text : null;
108
+ }
109
+
110
+ const SNAPSHOT = {
111
+ _schema: {
112
+ version: "1.2.0",
113
+ description: "Mission Control dashboard data model",
114
+ fields: {
115
+ generatedAt: "ISO-8601 timestamp of snapshot generation",
116
+ dashboardDataVersion: "Semantic version of the data model schema",
117
+ plans: "Active plans from .cursor/plans/*.plan.md with frontmatter parsing",
118
+ system:
119
+ "System metadata: repoRoot, listen port, handoff state, allowlisted config summary, package info, version, name, contextPacks",
120
+ agents: "Agent definitions from .cursor/agents/*.md",
121
+ commands: "Slash commands from .cursor/commands/*.md",
122
+ memory: "Memory records: error count, decision count, recent decisions",
123
+ git: "Git repository state: branch, dirty status, commit, ahead/behind, bounded files[]",
124
+ terminals:
125
+ "Active Cursor terminal sessions with metadata, output line count, and capped lastOutput",
126
+ processes: "Running process snapshots (node, serve.mjs, git operations)",
127
+ skills: "Available skills discovered in .cursor/skills/",
128
+ health: "Aggregated health status with per-check results",
129
+ missionControl: "Normalized now/activity/attention/plans view model (source-backed; bounded)",
130
+ },
131
+ },
132
+ generatedAt: new Date().toISOString(),
133
+ dashboardDataVersion: "1.2.0",
134
+ plans: [],
135
+ system: {
136
+ repoRoot: ROOT,
137
+ port: Number.parseInt(process.env.PORT || "3333", 10) || 3333,
138
+ },
139
+ agents: [],
140
+ commands: [],
141
+ memory: {},
142
+ git: {},
143
+ terminals: [],
144
+ processes: [],
145
+ skills: [],
146
+ health: { status: "ok", checks: [] },
147
+ missionControl: null,
148
+ };
149
+
150
+ // 1. Plans
151
+ const plansDir = join(ROOT, ".cursor", "plans");
152
+ if (existsSync(plansDir)) {
153
+ const files = readdirSync(plansDir).filter((f) => f.endsWith(".plan.md"));
154
+ for (const file of files) {
155
+ const content = readFileSync(join(plansDir, file), "utf-8");
156
+ const stats = statSync(join(plansDir, file));
157
+ const todos = [];
158
+ let overview = "";
159
+ let name = file.replace(/\.plan\.md$/, "");
160
+ let agent = null;
161
+
162
+ // Parse frontmatter
163
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
164
+ if (fmMatch) {
165
+ const fm = fmMatch[1];
166
+ const nameMatch = fm.match(/^name:\s*(.+)$/m);
167
+ if (nameMatch) name = nameMatch[1].trim();
168
+ const overviewMatch = fm.match(/^overview:\s*"(.+)"$/m);
169
+ if (overviewMatch) overview = overviewMatch[1];
170
+ const agentMatch = fm.match(/^agent:\s*(.+)$/m);
171
+ if (agentMatch) agent = agentMatch[1].trim().replace(/^"(.*)"$/, "$1");
172
+
173
+ // Parse todos
174
+ const todoRegex = /^\s*-\s+id:\s*(\S+)\s*\n\s*content:\s*"(.+)"\s*\n\s*status:\s*(\S+)/gm;
175
+ for (const m of fm.matchAll(todoRegex)) {
176
+ todos.push({ id: m[1], content: m[2], status: m[3] });
177
+ }
178
+ }
179
+
180
+ const totalTodos = todos.length;
181
+ const doneTodos = todos.filter((t) => t.status === "completed").length;
182
+ const progress = totalTodos > 0 ? Math.round((doneTodos / totalTodos) * 100) : 0;
183
+
184
+ SNAPSHOT.plans.push({
185
+ id: name,
186
+ file,
187
+ path: `.cursor/plans/${file}`,
188
+ overview,
189
+ agent,
190
+ progress,
191
+ todos: {
192
+ total: totalTodos,
193
+ completed: doneTodos,
194
+ pending: todos.filter((t) => t.status === "pending").length,
195
+ inProgress: todos.filter((t) => t.status === "in_progress").length,
196
+ items: todos,
197
+ },
198
+ modifiedAt: stats.mtime.toISOString(),
199
+ });
200
+ }
201
+ }
202
+
203
+ // 1b. Archived plans: only basenames are collected. A plan file present under
204
+ // .cursor/plans/archive/ resolves as the terminal `archived` lifecycle, so
205
+ // archiving a plan never promotes its review back into blocking attention.
206
+ const plansArchiveDir = join(plansDir, "archive");
207
+ const archivedPlanFiles = existsSync(plansArchiveDir)
208
+ ? readdirSync(plansArchiveDir).filter((f) => f.endsWith(".plan.md"))
209
+ : [];
210
+
211
+ // 2. HANDOFF (rich parse for Mission Control now/attention)
212
+ const handoffPath = join(ROOT, ".cursor", "HANDOFF.md");
213
+ if (existsSync(handoffPath)) {
214
+ const content = readFileSync(handoffPath, "utf-8");
215
+ const handoff = parseHandoffMarkdown(content);
216
+ if (handoff) SNAPSHOT.system.handoff = handoff;
217
+ }
218
+
219
+ // 3. Agents
220
+ const agentsDir = join(ROOT, ".cursor", "agents");
221
+ if (existsSync(agentsDir)) {
222
+ const files = readdirSync(agentsDir).filter((f) => f.endsWith(".md"));
223
+ for (const file of files) {
224
+ const content = readFileSync(join(agentsDir, file), "utf-8");
225
+ const name = file.replace(/\.md$/, "");
226
+ const descMatch = content.match(/(?:description|summary|#+ .+?)\n*([^#\n]{30,200})/);
227
+ SNAPSHOT.agents.push({
228
+ id: name,
229
+ file,
230
+ path: `.cursor/agents/${file}`,
231
+ description: descMatch ? descMatch[1].trim().slice(0, 120) : "",
232
+ });
233
+ }
234
+ }
235
+
236
+ // 4. Commands
237
+ const commandsDir = join(ROOT, ".cursor", "commands");
238
+ if (existsSync(commandsDir)) {
239
+ const files = readdirSync(commandsDir).filter((f) => f.endsWith(".md"));
240
+ for (const file of files) {
241
+ const name = file.replace(/\.md$/, "");
242
+ SNAPSHOT.commands.push({ id: name, file, path: `.cursor/commands/${file}` });
243
+ }
244
+ }
245
+
246
+ // 5. Memory
247
+ const memoryErrorsDir = join(ROOT, ".cursor", "memory", "errors");
248
+ const memoryDecisionsDir = join(ROOT, ".cursor", "memory", "decisions");
249
+ if (existsSync(memoryErrorsDir)) {
250
+ const errorFiles = readdirSync(memoryErrorsDir).filter((f) => f.endsWith(".md"));
251
+ SNAPSHOT.memory.errors = errorFiles.length;
252
+ SNAPSHOT.memory.errorEntries = errorFiles.map((f) => {
253
+ const id = f.replace(/\.md$/, "");
254
+ let modifiedAt = null;
255
+ try {
256
+ modifiedAt = statSync(join(memoryErrorsDir, f)).mtime.toISOString();
257
+ } catch {
258
+ modifiedAt = null;
259
+ }
260
+ return { id, modifiedAt };
261
+ });
262
+ }
263
+ if (existsSync(memoryDecisionsDir)) {
264
+ const files = readdirSync(memoryDecisionsDir).filter((f) => f.endsWith(".md"));
265
+ SNAPSHOT.memory.decisions = files.length;
266
+ SNAPSHOT.memory.decisionEntries = files.map((f) => {
267
+ const id = f.replace(/\.md$/, "");
268
+ let modifiedAt = null;
269
+ try {
270
+ modifiedAt = statSync(join(memoryDecisionsDir, f)).mtime.toISOString();
271
+ } catch {
272
+ modifiedAt = null;
273
+ }
274
+ return { id, modifiedAt };
275
+ });
276
+ SNAPSHOT.memory.recentDecisions = files
277
+ .slice(-5)
278
+ .reverse()
279
+ .map((f) => ({
280
+ id: f.replace(/\.md$/, ""),
281
+ path: `.cursor/memory/decisions/${f}`,
282
+ }));
283
+ }
284
+
285
+ // 6. Git
286
+ try {
287
+ const gitOpts = { cwd: ROOT, encoding: "utf-8", timeout: 5000 };
288
+ const branch = execSync("git rev-parse --abbrev-ref HEAD", gitOpts).trim();
289
+ const status = execSync("git status --short", gitOpts).trim();
290
+ const lastCommit = execSync("git log -1 --oneline", gitOpts).trim();
291
+ let ahead = 0;
292
+ let behind = 0;
293
+ try {
294
+ ahead =
295
+ Number.parseInt(execSync("git rev-list --count origin/main..HEAD", gitOpts).trim(), 10) || 0;
296
+ } catch {
297
+ /* no upstream */
298
+ }
299
+ try {
300
+ behind =
301
+ Number.parseInt(execSync("git rev-list --count HEAD..origin/main", gitOpts).trim(), 10) || 0;
302
+ } catch {
303
+ /* no upstream */
304
+ }
305
+
306
+ const parsed = parseGitStatusShort(status);
307
+ let recentLog = [];
308
+ try {
309
+ recentLog = execSync(`git log --oneline -n ${MAX_GIT_ACTIVITY}`, gitOpts)
310
+ .trim()
311
+ .split("\n")
312
+ .filter(Boolean);
313
+ } catch {
314
+ recentLog = [];
315
+ }
316
+
317
+ SNAPSHOT.git = {
318
+ branch: truncateStr(branch, MAX_STRING.branch),
319
+ dirty: parsed.total > 0,
320
+ dirtyCount: parsed.total,
321
+ files: parsed.files,
322
+ filesTruncated: parsed.truncated,
323
+ lastCommit: truncateStr(lastCommit, MAX_STRING.lastCommit),
324
+ ahead,
325
+ behind,
326
+ };
327
+ SNAPSHOT._gitRecentLog = recentLog;
328
+ } catch {
329
+ SNAPSHOT.git = { error: "unable to read git state" };
330
+ SNAPSHOT._gitRecentLog = [];
331
+ }
332
+
333
+ // 7. Terminals (read from Cursor terminal files)
334
+ const terminalsDir = resolve(process.env.HOME || "~", ".cursor", "projects");
335
+ // Derive project path from ROOT rather than hardcoding a specific slug
336
+ const projectSlug = ROOT.replace(/\//g, "-").replace(/^-/, "");
337
+ const terminalProjectPath = join(terminalsDir, projectSlug, "terminals");
338
+
339
+ if (existsSync(terminalProjectPath)) {
340
+ try {
341
+ const files = readdirSync(terminalProjectPath)
342
+ .filter((f) => f.endsWith(".txt"))
343
+ .slice(0, MAX_TERMINALS);
344
+ for (const file of files) {
345
+ const full = join(terminalProjectPath, file);
346
+ const raw = readFileSync(full, "utf-8");
347
+ // Cap huge terminal dumps: only header meta + a line count estimate is needed
348
+ const content = raw.length > MAX_TERMINAL_BYTES ? raw.slice(0, MAX_TERMINAL_BYTES) : raw;
349
+ const lines = content.split("\n");
350
+ const meta = {};
351
+ for (const line of lines.slice(0, 15)) {
352
+ if (line.startsWith("pid:")) meta.pid = line.slice(4).trim();
353
+ if (line.startsWith("cwd:")) meta.cwd = line.slice(4).trim();
354
+ if (line.startsWith("command:")) meta.lastCommand = line.slice(8).trim();
355
+ if (line.startsWith("last_command:")) meta.lastCommand = line.slice(13).trim();
356
+ if (line.startsWith("last_exit_code:")) meta.lastExitCode = line.slice(15).trim();
357
+ }
358
+ const outputLines = lines.slice(10).filter((l) => {
359
+ return l.trim() && !l.startsWith("---");
360
+ }).length;
361
+ const lastOutput = extractLastOutput(content);
362
+ const entry = {
363
+ id: file,
364
+ ...redactTerminalMeta(meta),
365
+ outputLines,
366
+ };
367
+ if (lastOutput) entry.lastOutput = lastOutput;
368
+ SNAPSHOT.terminals.push(entry);
369
+ }
370
+ } catch {
371
+ // Ignore terminal read errors
372
+ }
373
+ }
374
+
375
+ // 8. Config
376
+ const configPath = join(ROOT, ".cursor", "context", "config.json");
377
+ if (existsSync(configPath)) {
378
+ try {
379
+ const rawConfig = JSON.parse(readFileSync(configPath, "utf-8"));
380
+ SNAPSHOT.system.config = allowlistConfig(rawConfig);
381
+ } catch {
382
+ SNAPSHOT.system.config = { error: "parse error" };
383
+ }
384
+ }
385
+
386
+ // 9. Package.json
387
+ const pkgPath = join(ROOT, "package.json");
388
+ if (existsSync(pkgPath)) {
389
+ try {
390
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
391
+ SNAPSHOT.system.version = pkg.version;
392
+ SNAPSHOT.system.name = pkg.name;
393
+ } catch {
394
+ // ignore
395
+ }
396
+ }
397
+
398
+ // 11. Context Packs
399
+ const contextCurrentDir = join(ROOT, ".cursor", "context", "current");
400
+ if (existsSync(contextCurrentDir)) {
401
+ try {
402
+ const contextFiles = readdirSync(contextCurrentDir).filter((f) => f.endsWith(".md"));
403
+ SNAPSHOT.system.contextPacks = contextFiles.map((f) => ({
404
+ id: f.replace(/\.md$/, ""),
405
+ file: f,
406
+ path: `.cursor/context/current/${f}`,
407
+ }));
408
+ } catch {
409
+ SNAPSHOT.system.contextPacks = [];
410
+ }
411
+ }
412
+
413
+ // 12. Skills discovery
414
+ const skillsDir = join(ROOT, ".cursor", "skills");
415
+ if (existsSync(skillsDir)) {
416
+ try {
417
+ function scanSkills(dir, category = "") {
418
+ const entries = readdirSync(dir, { withFileTypes: true });
419
+ for (const entry of entries) {
420
+ const fullPath = join(dir, entry.name);
421
+ if (entry.isDirectory()) {
422
+ scanSkills(fullPath, entry.name);
423
+ } else if (entry.name === "SKILL.md") {
424
+ const relativeDir = dir.replace(`${skillsDir}/`, "");
425
+ const raw = readFileSync(fullPath, "utf-8");
426
+ const titleMatch = raw.match(/^# (.+)$/m);
427
+ const descMatch = raw.match(/\n\n(.{20,200})/);
428
+ SNAPSHOT.skills.push({
429
+ id: relativeDir,
430
+ category: category || "root",
431
+ title: titleMatch ? titleMatch[1].trim() : relativeDir.split("/").pop(),
432
+ description: descMatch ? descMatch[1].trim().slice(0, 150) : "",
433
+ file: fullPath.replace(`${ROOT}/`, ""),
434
+ });
435
+ }
436
+ }
437
+ }
438
+ scanSkills(skillsDir);
439
+ } catch {
440
+ // Ignore skills scan errors
441
+ }
442
+ }
443
+
444
+ // 13. Process scanning (capped list: the UI only needs a sample of relevant procs)
445
+ try {
446
+ const psOutput = execSync("ps -axo pid=,pcpu=,pmem=,command=", {
447
+ encoding: "utf-8",
448
+ timeout: 3000,
449
+ }).trim();
450
+ if (psOutput) {
451
+ const interesting = [];
452
+ for (const line of psOutput.split("\n")) {
453
+ const trimmed = line.trim();
454
+ if (!trimmed) continue;
455
+ if (!/node|git|serve\.mjs|dashboard/i.test(trimmed)) continue;
456
+ if (/grep|dashboard-data/.test(trimmed)) continue;
457
+ const parts = trimmed.split(/\s+/);
458
+ const pid = parts[0];
459
+ const cpu = parts[1];
460
+ const mem = parts[2];
461
+ const cmd = parts.slice(3).join(" ") || "unknown";
462
+ let label = "other";
463
+ if (cmd.includes("serve.mjs") || cmd.includes("node dashboard")) label = "dashboard-server";
464
+ else if (/\bgit\b/.test(cmd)) label = "git";
465
+ else if (cmd.includes("node")) label = "node";
466
+ interesting.push({
467
+ pid,
468
+ cpu,
469
+ mem,
470
+ command: truncateStr(cmd, MAX_STRING.processCommand),
471
+ label,
472
+ });
473
+ if (interesting.length >= MAX_PROCESSES) break;
474
+ }
475
+ SNAPSHOT.processes = interesting;
476
+ }
477
+ } catch {
478
+ SNAPSHOT.processes = [];
479
+ }
480
+
481
+ // 10. Health checks (originally)
482
+ const checks = [
483
+ { id: "plans", label: "Plans directory", ok: existsSync(plansDir) && SNAPSHOT.plans.length > 0 },
484
+ // Present + parseable HANDOFF is healthy even when Plan is none/null (idle).
485
+ { id: "handoff", label: "HANDOFF.md", ok: !!SNAPSHOT.system.handoff },
486
+ { id: "agents", label: "Agents", ok: SNAPSHOT.agents.length > 0 },
487
+ { id: "commands", label: "Commands", ok: SNAPSHOT.commands.length > 0 },
488
+ {
489
+ id: "memory",
490
+ label: "Memory (errors + decisions)",
491
+ ok: (SNAPSHOT.memory.errors || 0) + (SNAPSHOT.memory.decisions || 0) > 0,
492
+ },
493
+ { id: "git", label: "Git repository", ok: !!SNAPSHOT.git.branch },
494
+ { id: "config", label: "Config", ok: !!SNAPSHOT.system.config },
495
+ ];
496
+
497
+ SNAPSHOT.health.checks = checks;
498
+ SNAPSHOT.health.status = checks.every((c) => c.ok)
499
+ ? "ok"
500
+ : checks.filter((c) => !c.ok).length <= 2
501
+ ? "warning"
502
+ : "degraded";
503
+
504
+ /**
505
+ * Agent-prompt detection contract (fs half).
506
+ *
507
+ * The project transcript store lives at
508
+ * `~/.cursor/projects/<project-slug>/agent-transcripts/<id>/<id>.jsonl`, where
509
+ * `<project-slug>` is the repo root path with slashes turned into dashes (the
510
+ * same derivation used for the terminals directory). Each `<id>` directory
511
+ * holds one main transcript file named after the id; nested `subagents/`
512
+ * transcripts are ignored so a worker question never masquerades as a user
513
+ * prompt. The scan is read-only and bounded: it skips transcripts outside a
514
+ * 30-day recency window, skips files larger than the byte cap, reads at most
515
+ * MAX_TRANSCRIPT_FILES of the most recent, and returns at most
516
+ * MAX_AGENT_PROMPTS items. The awaiting-a-reply decision itself lives in
517
+ * `detectAwaitingPrompt`. A missing or unreadable store yields an empty list,
518
+ * never an error state.
519
+ */
520
+ function collectAgentPrompts() {
521
+ const projectsDir = resolve(process.env.HOME || "~", ".cursor", "projects");
522
+ const slug = ROOT.replace(/\//g, "-").replace(/^-/, "");
523
+ const transcriptsDir = join(projectsDir, slug, "agent-transcripts");
524
+ if (!existsSync(transcriptsDir)) return [];
525
+
526
+ const prompts = [];
527
+ try {
528
+ const now = Date.now();
529
+ const candidates = [];
530
+ for (const dirent of readdirSync(transcriptsDir, { withFileTypes: true })) {
531
+ if (!dirent.isDirectory()) continue;
532
+ const id = dirent.name;
533
+ const file = join(transcriptsDir, id, `${id}.jsonl`);
534
+ if (!existsSync(file)) continue;
535
+ let stat;
536
+ try {
537
+ stat = statSync(file);
538
+ } catch {
539
+ continue;
540
+ }
541
+ if (now - stat.mtimeMs > TRANSCRIPT_RECENCY_MS) continue;
542
+ if (stat.size > MAX_TRANSCRIPT_BYTES) continue;
543
+ candidates.push({ id, file, mtime: stat.mtime });
544
+ }
545
+ candidates.sort((a, b) => b.mtime - a.mtime);
546
+
547
+ for (const candidate of candidates.slice(0, MAX_TRANSCRIPT_FILES)) {
548
+ let raw;
549
+ try {
550
+ raw = readFileSync(candidate.file, "utf-8");
551
+ } catch {
552
+ continue;
553
+ }
554
+ const entries = [];
555
+ for (const line of raw.split("\n")) {
556
+ const trimmed = line.trim();
557
+ if (!trimmed) continue;
558
+ try {
559
+ entries.push(JSON.parse(trimmed));
560
+ } catch {
561
+ // Ignore a malformed line; a partial write must not drop the transcript.
562
+ }
563
+ }
564
+ const awaiting = detectAwaitingPrompt(entries);
565
+ if (!awaiting) continue;
566
+ prompts.push({
567
+ chatId: candidate.id,
568
+ label: awaiting.label,
569
+ // Untruncated detection value for lifecycle clear (FR-SAC-01); not rendered.
570
+ labelFull: awaiting.labelFull,
571
+ chatSnippet: extractChatSnippet(entries),
572
+ quietAt: candidate.mtime.toISOString(),
573
+ });
574
+ if (prompts.length >= MAX_AGENT_PROMPTS) break;
575
+ }
576
+ } catch {
577
+ return [];
578
+ }
579
+ return prompts;
580
+ }
581
+
582
+ /**
583
+ * External review report scan (fs half).
584
+ *
585
+ * Reports written by `/plan-external-review` live directly in
586
+ * `.cursor/memory/` as `plan-monitor-<slug>.md`. The scan is read-only and
587
+ * bounded: it skips reports outside a 90-day recency window, skips files
588
+ * larger than the byte cap, and reads at most MAX_REPORT_FILES of the most
589
+ * recent; `buildExternalReportItems` then caps the surfaced items at
590
+ * MAX_EXTERNAL_REPORTS. The triaged-or-not decision itself
591
+ * lives in `isReportTriaged`. A missing or unreadable `.cursor/memory/`
592
+ * directory yields an empty list, never an error state.
593
+ */
594
+ function collectExternalReports() {
595
+ const memoryDir = join(ROOT, ".cursor", "memory");
596
+ if (!existsSync(memoryDir)) return [];
597
+
598
+ const reports = [];
599
+ try {
600
+ const now = Date.now();
601
+ const candidates = [];
602
+ for (const name of readdirSync(memoryDir)) {
603
+ if (!EXTERNAL_REPORT_FILE_RE.test(name)) continue;
604
+ const file = join(memoryDir, name);
605
+ let stat;
606
+ try {
607
+ stat = statSync(file);
608
+ } catch {
609
+ continue;
610
+ }
611
+ if (!stat.isFile()) continue;
612
+ if (now - stat.mtimeMs > REPORT_RECENCY_MS) continue;
613
+ if (stat.size > MAX_REPORT_BYTES) continue;
614
+ candidates.push({ name, file, mtime: stat.mtime });
615
+ }
616
+ candidates.sort((a, b) => b.mtime - a.mtime);
617
+
618
+ for (const candidate of candidates.slice(0, MAX_REPORT_FILES)) {
619
+ let content;
620
+ try {
621
+ content = readFileSync(candidate.file, "utf-8");
622
+ } catch {
623
+ continue;
624
+ }
625
+ const report = parseExternalReport({
626
+ file: candidate.name,
627
+ content,
628
+ modifiedAt: candidate.mtime.toISOString(),
629
+ });
630
+ if (!report) continue;
631
+ reports.push(report);
632
+ }
633
+ } catch {
634
+ return [];
635
+ }
636
+ return reports;
637
+ }
638
+
639
+ // 14. Mission Control semantic view model (now / activity / attention)
640
+ let readinessPending = [];
641
+ const readinessPath = join(ROOT, ".cursor", "context", "readiness.json");
642
+ if (existsSync(readinessPath)) {
643
+ try {
644
+ const readiness = JSON.parse(readFileSync(readinessPath, "utf-8"));
645
+ readinessPending = collectReadinessPendingFromReport(readiness);
646
+ } catch {
647
+ readinessPending = [];
648
+ }
649
+ }
650
+
651
+ /**
652
+ * Explicit non-essential deferrals from config (checkId + reason).
653
+ * allowlistConfig strips deferredItems from the public snapshot; Mission Control
654
+ * still needs them so Checklist can clear advisories without inventing ready.
655
+ */
656
+ function collectOnboardingDeferredCheckIds() {
657
+ if (!existsSync(configPath)) return [];
658
+ try {
659
+ const rawConfig = JSON.parse(readFileSync(configPath, "utf-8"));
660
+ return collectDeferredCheckIds(rawConfig);
661
+ } catch {
662
+ return [];
663
+ }
664
+ }
665
+
666
+ /**
667
+ * Local Field Report dismissals (IDs only). Missing or unreadable file → [].
668
+ * Path is gitignored like readiness.json; never carries transcript body.
669
+ */
670
+ function collectFieldReportDismissedIds() {
671
+ const dismissalsPath = join(ROOT, ".cursor", "context", "field-report-dismissals.json");
672
+ if (!existsSync(dismissalsPath)) return [];
673
+ try {
674
+ const raw = JSON.parse(readFileSync(dismissalsPath, "utf-8"));
675
+ return dismissedAttentionIds(parseFieldReportDismissals(raw));
676
+ } catch {
677
+ return [];
678
+ }
679
+ }
680
+
681
+ /**
682
+ * Local Current mission timing ledger. Missing or unreadable → empty ledger.
683
+ * Gitignored observation store; not a UI mutation API.
684
+ */
685
+ function collectMissionTimingLedger() {
686
+ const ledgerPath = join(ROOT, MISSION_TIMING_LEDGER_REL);
687
+ if (!existsSync(ledgerPath)) return parseMissionTimingLedger(null);
688
+ try {
689
+ const raw = JSON.parse(readFileSync(ledgerPath, "utf-8"));
690
+ return parseMissionTimingLedger(raw);
691
+ } catch {
692
+ return parseMissionTimingLedger(null);
693
+ }
694
+ }
695
+
696
+ /**
697
+ * Field Report activity cadence ledger. Missing or unreadable → empty.
698
+ * Agents bump via field-report-cadence-bump.sh; dashboard only reads.
699
+ */
700
+ function collectCadenceLedger() {
701
+ const ledgerPath = join(ROOT, FIELD_REPORT_CADENCE_LEDGER_REL);
702
+ if (!existsSync(ledgerPath)) return parseCadenceLedger(null);
703
+ try {
704
+ const raw = JSON.parse(readFileSync(ledgerPath, "utf-8"));
705
+ return parseCadenceLedger(raw);
706
+ } catch {
707
+ return parseCadenceLedger(null);
708
+ }
709
+ }
710
+
711
+ /** Cadence config from local config.json (defaults when missing). */
712
+ function collectCadenceConfig() {
713
+ if (!existsSync(configPath)) return parseFieldReportReviewCadenceConfig(null);
714
+ try {
715
+ const rawConfig = JSON.parse(readFileSync(configPath, "utf-8"));
716
+ return parseFieldReportReviewCadenceConfig(rawConfig);
717
+ } catch {
718
+ return parseFieldReportReviewCadenceConfig(null);
719
+ }
720
+ }
721
+
722
+ /** Persist ledger only when serialized content changes (avoids SSE watch loops). */
723
+ function persistMissionTimingLedger(nextLedger) {
724
+ const ledgerPath = join(ROOT, MISSION_TIMING_LEDGER_REL);
725
+ const nextText = serializeMissionTimingLedger(nextLedger);
726
+ let prevText = "";
727
+ if (existsSync(ledgerPath)) {
728
+ try {
729
+ prevText = readFileSync(ledgerPath, "utf-8");
730
+ } catch {
731
+ prevText = "";
732
+ }
733
+ }
734
+ if (prevText === nextText) return;
735
+ mkdirSync(dirname(ledgerPath), { recursive: true });
736
+ writeFileSync(ledgerPath, nextText, "utf-8");
737
+ }
738
+
739
+ /** Flight Log history ledger (past Gaps). Write-on-change like mission timing. */
740
+ function collectFlightLogLedger() {
741
+ const ledgerPath = join(ROOT, FLIGHT_LOG_LEDGER_REL);
742
+ if (!existsSync(ledgerPath)) return parseFlightLogLedger(null);
743
+ try {
744
+ const raw = JSON.parse(readFileSync(ledgerPath, "utf-8"));
745
+ return parseFlightLogLedger(raw);
746
+ } catch {
747
+ return parseFlightLogLedger(null);
748
+ }
749
+ }
750
+
751
+ function persistFlightLogLedger(nextLedger) {
752
+ const ledgerPath = join(ROOT, FLIGHT_LOG_LEDGER_REL);
753
+ const nextText = serializeFlightLogLedger(nextLedger);
754
+ let prevText = "";
755
+ if (existsSync(ledgerPath)) {
756
+ try {
757
+ prevText = readFileSync(ledgerPath, "utf-8");
758
+ } catch {
759
+ prevText = "";
760
+ }
761
+ }
762
+ if (prevText === nextText) return;
763
+ mkdirSync(dirname(ledgerPath), { recursive: true });
764
+ writeFileSync(ledgerPath, nextText, "utf-8");
765
+ }
766
+
767
+ /** Prior inventory baseline from serve.mjs (JSON); cold start → no inventory events. */
768
+ function readPreviousInventory() {
769
+ const raw = process.env.AGENT_KIT_PREV_INVENTORY;
770
+ if (!raw || typeof raw !== "string") return null;
771
+ try {
772
+ const parsed = JSON.parse(raw);
773
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
774
+ } catch {
775
+ return null;
776
+ }
777
+ }
778
+
779
+ {
780
+ const missionControl = buildMissionControlView({
781
+ plans: SNAPSHOT.plans,
782
+ handoff: SNAPSHOT.system.handoff || null,
783
+ gitLogLines: SNAPSHOT._gitRecentLog || [],
784
+ terminals: SNAPSHOT.terminals,
785
+ readinessPending,
786
+ deferredCheckIds: collectOnboardingDeferredCheckIds(),
787
+ agentPrompts: collectAgentPrompts(),
788
+ externalReports: collectExternalReports(),
789
+ dismissedIds: collectFieldReportDismissedIds(),
790
+ archivedPlanFiles,
791
+ agents: SNAPSHOT.agents,
792
+ skills: SNAPSHOT.skills,
793
+ commands: SNAPSHOT.commands,
794
+ memory: SNAPSHOT.memory,
795
+ previousInventory: readPreviousInventory(),
796
+ timingLedger: collectMissionTimingLedger(),
797
+ flightLogLedger: collectFlightLogLedger(),
798
+ cadenceLedger: collectCadenceLedger(),
799
+ cadenceConfig: collectCadenceConfig(),
800
+ });
801
+ persistMissionTimingLedger(missionControl.timingLedger);
802
+ persistFlightLogLedger(missionControl.flightLogLedger);
803
+ // Do not expose the writable ledger blobs on the public snapshot wire.
804
+ const {
805
+ timingLedger: _timingLedger,
806
+ flightLogLedger: _flightLogLedger,
807
+ ...missionControlPublic
808
+ } = missionControl;
809
+ SNAPSHOT.missionControl = missionControlPublic;
810
+ }
811
+ SNAPSHOT._gitRecentLog = undefined;
812
+
813
+ process.stdout.write(JSON.stringify(SNAPSHOT, null, 2));