@dadado/agent-kit-cli 4.7.2 → 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.
- package/dashboard/dashboard-data.mjs +813 -0
- package/dashboard/dashboard.html +6882 -0
- package/dashboard/lib/guards.mjs +917 -0
- package/dashboard/lib/live-refresh.mjs +147 -0
- package/dashboard/lib/semantic-model.d.mts +34 -0
- package/dashboard/lib/semantic-model.mjs +3581 -0
- package/dashboard/logo-cursor.svg +10 -0
- package/dashboard/logo.svg +1 -0
- package/dashboard/serve.mjs +550 -0
- package/dashboard/start-broadcast.mjs +231 -0
- package/dashboard/start.mjs +292 -0
- package/dist/index.js +1782 -285
- package/package.json +5 -3
|
@@ -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));
|