feat: add /gsd logs command to browse activity, debug, and metrics logs (#1162)

Adds a new /gsd logs command for browsing and inspecting GSD's existing
logging infrastructure. Users can now discover and review activity logs,
debug logs, and metrics without navigating the filesystem manually.

Subcommands:
  /gsd logs           — List recent activity + debug logs with metrics summary
  /gsd logs <N>       — Show summary of activity log #N (tool calls, files, errors)
  /gsd logs debug     — List debug log files
  /gsd logs debug <N> — Show debug log summary (events, duration, errors)
  /gsd logs tail [N]  — Show last N activity log summaries (default 5)
  /gsd logs clear     — Remove old activity and debug logs (keeps recent 5)

Addresses #1161 — users needed a way to understand what happened during
auto-mode sessions for debugging.
This commit is contained in:
Jeremy McSpadden 2026-03-18 10:10:11 -05:00 committed by GitHub
parent e5d40a2591
commit 8b70fc03f6
3 changed files with 797 additions and 0 deletions

View file

@ -0,0 +1,537 @@
/**
* /gsd logs Browse activity logs, debug logs, and metrics.
*
* Subcommands:
* /gsd logs List recent activity + debug logs
* /gsd logs <N> Show summary of activity log #N
* /gsd logs debug List debug log files
* /gsd logs debug <N> Show debug log summary #N
* /gsd logs tail [N] Show last N activity log entries (default 5)
* /gsd logs clear Remove old activity and debug logs
*/
import type { ExtensionCommandContext } from "@gsd/pi-coding-agent";
import { existsSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { gsdRoot } from "./paths.js";
// ─── Types ──────────────────────────────────────────────────────────────────
interface LogEntry {
seq: number;
filename: string;
unitType: string;
unitId: string;
size: number;
mtime: Date;
}
interface DebugLogEntry {
filename: string;
size: number;
mtime: Date;
}
// ─── Helpers ────────────────────────────────────────────────────────────────
function activityDir(basePath: string): string {
return join(gsdRoot(basePath), "activity");
}
function debugDir(basePath: string): string {
return join(gsdRoot(basePath), "debug");
}
function listActivityLogs(basePath: string): LogEntry[] {
const dir = activityDir(basePath);
if (!existsSync(dir)) return [];
const entries: LogEntry[] = [];
try {
for (const f of readdirSync(dir)) {
if (!f.endsWith(".jsonl")) continue;
// Filename format: {seq}-{unitType}-{unitId}.jsonl
// unitType is lowercase-with-hyphens (e.g., "execute-task", "complete-slice")
// unitId starts with M followed by digits (e.g., "M001-S01-T01")
const match = f.match(/^(\d+)-([\w-]+?)-(M\d[\w-]*)\.jsonl$/);
if (!match) continue;
const filePath = join(dir, f);
let stat;
try { stat = statSync(filePath); } catch { continue; }
entries.push({
seq: parseInt(match[1], 10),
filename: f,
unitType: match[2],
unitId: match[3].replace(/-/g, "/"),
size: stat.size,
mtime: stat.mtime,
});
}
} catch { /* dir not readable */ }
return entries.sort((a, b) => a.seq - b.seq);
}
function listDebugLogs(basePath: string): DebugLogEntry[] {
const dir = debugDir(basePath);
if (!existsSync(dir)) return [];
const entries: DebugLogEntry[] = [];
try {
for (const f of readdirSync(dir)) {
if (!f.endsWith(".log")) continue;
const filePath = join(dir, f);
let stat;
try { stat = statSync(filePath); } catch { continue; }
entries.push({ filename: f, size: stat.size, mtime: stat.mtime });
}
} catch { /* dir not readable */ }
return entries.sort((a, b) => a.mtime.getTime() - b.mtime.getTime());
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
}
function formatAge(date: Date): string {
const ms = Date.now() - date.getTime();
const mins = Math.floor(ms / 60_000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return `${days}d ago`;
}
/**
* Extract a summary from an activity log JSONL file.
* Parses the entries to count tool calls, errors, and extract key events.
*/
function summarizeActivityLog(filePath: string): {
toolCalls: number;
errors: number;
filesWritten: string[];
commandsRun: Array<{ command: string; failed: boolean }>;
lastReasoning: string;
entryCount: number;
} {
const result = {
toolCalls: 0,
errors: 0,
filesWritten: new Set<string>(),
commandsRun: [] as Array<{ command: string; failed: boolean }>,
lastReasoning: "",
entryCount: 0,
};
let raw: string;
try { raw = readFileSync(filePath, "utf-8"); } catch { return { ...result, filesWritten: [] }; }
const lines = raw.split("\n").filter(l => l.trim());
result.entryCount = lines.length;
for (const line of lines) {
let entry: Record<string, unknown>;
try { entry = JSON.parse(line); } catch { continue; }
// Count tool calls
if (entry.type === "toolCall" || (entry.role === "assistant" && entry.content && Array.isArray(entry.content))) {
if (entry.type === "toolCall") {
result.toolCalls++;
const name = entry.name as string | undefined;
const args = entry.arguments as Record<string, unknown> | undefined;
if (name === "write" || name === "edit") {
const path = args?.file_path as string | undefined;
if (path) result.filesWritten.add(path);
}
if (name === "bash") {
const cmd = args?.command as string | undefined;
if (cmd) result.commandsRun.push({ command: cmd.slice(0, 80), failed: false });
}
}
}
// Count errors
if (entry.role === "toolResult" && entry.isError) {
result.errors++;
// Mark last command as failed
if (result.commandsRun.length > 0) {
result.commandsRun[result.commandsRun.length - 1].failed = true;
}
}
// Track assistant reasoning
if (entry.role === "assistant" && typeof entry.content === "string") {
result.lastReasoning = entry.content.slice(0, 200);
}
}
return {
...result,
filesWritten: [...result.filesWritten],
};
}
/**
* Extract summary events from a debug log file.
*/
function summarizeDebugLog(filePath: string): {
events: number;
duration: string;
dispatches: number;
errors: Array<{ event: string; message: string }>;
} {
const result = {
events: 0,
duration: "unknown",
dispatches: 0,
errors: [] as Array<{ event: string; message: string }>,
};
let raw: string;
try { raw = readFileSync(filePath, "utf-8"); } catch { return result; }
const lines = raw.split("\n").filter(l => l.trim());
result.events = lines.length;
let firstTs = 0;
let lastTs = 0;
for (const line of lines) {
let entry: Record<string, unknown>;
try { entry = JSON.parse(line); } catch { continue; }
const ts = entry.ts as string | undefined;
if (ts) {
const t = new Date(ts).getTime();
if (!firstTs) firstTs = t;
lastTs = t;
}
const event = entry.event as string | undefined;
if (!event) continue;
if (event === "debug-summary") {
result.dispatches = (entry.dispatches as number) ?? 0;
}
if (event.includes("error") || event.includes("failed")) {
const msg = (entry.error as string) ?? (entry.message as string) ?? JSON.stringify(entry).slice(0, 100);
result.errors.push({ event, message: msg });
}
}
if (firstTs && lastTs) {
const elapsed = lastTs - firstTs;
const mins = Math.floor(elapsed / 60_000);
if (mins < 1) result.duration = `${Math.floor(elapsed / 1000)}s`;
else if (mins < 60) result.duration = `${mins}m`;
else result.duration = `${Math.floor(mins / 60)}h ${mins % 60}m`;
}
return result;
}
// ─── Main Handler ───────────────────────────────────────────────────────────
export async function handleLogs(args: string, ctx: ExtensionCommandContext): Promise<void> {
const basePath = process.cwd();
const parts = args.trim().split(/\s+/).filter(Boolean);
const subCmd = parts[0] ?? "";
// /gsd logs clear
if (subCmd === "clear") {
await handleLogsClear(basePath, ctx);
return;
}
// /gsd logs debug [N]
if (subCmd === "debug") {
const idx = parts[1] ? parseInt(parts[1], 10) : undefined;
await handleLogsDebug(basePath, ctx, idx);
return;
}
// /gsd logs tail [N]
if (subCmd === "tail") {
const count = parts[1] ? parseInt(parts[1], 10) : 5;
await handleLogsTail(basePath, ctx, count);
return;
}
// /gsd logs <N> — show specific activity log
if (subCmd && /^\d+$/.test(subCmd)) {
const seq = parseInt(subCmd, 10);
await handleLogsShow(basePath, ctx, seq);
return;
}
// /gsd logs — list overview
await handleLogsList(basePath, ctx);
}
// ─── Subcommand Handlers ────────────────────────────────────────────────────
async function handleLogsList(basePath: string, ctx: ExtensionCommandContext): Promise<void> {
const activities = listActivityLogs(basePath);
const debugLogs = listDebugLogs(basePath);
if (activities.length === 0 && debugLogs.length === 0) {
ctx.ui.notify(
"No logs found.\n\nActivity logs are created during auto-mode.\nDebug logs require GSD_DEBUG=1.",
"info",
);
return;
}
const lines: string[] = [];
if (activities.length > 0) {
lines.push("Activity Logs (.gsd/activity/):");
lines.push(" # Unit Type Unit ID Size Age");
lines.push(" " + "─".repeat(70));
// Show last 15 entries
const recent = activities.slice(-15);
for (const e of recent) {
const seq = String(e.seq).padStart(3, " ");
const type = e.unitType.padEnd(18, " ");
const id = e.unitId.padEnd(20, " ");
const size = formatSize(e.size).padStart(7, " ");
const age = formatAge(e.mtime);
lines.push(` ${seq} ${type} ${id} ${size} ${age}`);
}
if (activities.length > 15) {
lines.push(` ... and ${activities.length - 15} older entries`);
}
lines.push("");
lines.push(" View details: /gsd logs <#>");
}
if (debugLogs.length > 0) {
lines.push("");
lines.push("Debug Logs (.gsd/debug/):");
for (let i = 0; i < debugLogs.length; i++) {
const d = debugLogs[i];
const size = formatSize(d.size).padStart(7, " ");
const age = formatAge(d.mtime);
lines.push(` ${i + 1}. ${d.filename} ${size} ${age}`);
}
lines.push("");
lines.push(" View details: /gsd logs debug <#>");
}
// Metrics summary
const metricsPath = join(gsdRoot(basePath), "metrics.json");
if (existsSync(metricsPath)) {
try {
const metrics = JSON.parse(readFileSync(metricsPath, "utf-8"));
const units = metrics?.units;
if (Array.isArray(units) && units.length > 0) {
const totalCost = units.reduce((sum: number, u: Record<string, unknown>) => sum + ((u.cost as number) ?? 0), 0);
const totalTokens = units.reduce((sum: number, u: Record<string, unknown>) => {
const t = u.tokens as Record<string, number> | undefined;
return sum + (t?.total ?? 0);
}, 0);
lines.push("");
lines.push(`Metrics: ${units.length} units tracked · $${totalCost.toFixed(2)} · ${(totalTokens / 1000).toFixed(0)}K tokens`);
}
} catch { /* ignore */ }
}
lines.push("");
lines.push("Tip: Enable debug logging with GSD_DEBUG=1 before /gsd auto");
ctx.ui.notify(lines.join("\n"), "info");
}
async function handleLogsShow(basePath: string, ctx: ExtensionCommandContext, seq: number): Promise<void> {
const activities = listActivityLogs(basePath);
const entry = activities.find(e => e.seq === seq);
if (!entry) {
ctx.ui.notify(`Activity log #${seq} not found. Run /gsd logs to see available logs.`, "warning");
return;
}
const filePath = join(activityDir(basePath), entry.filename);
const summary = summarizeActivityLog(filePath);
const lines: string[] = [];
lines.push(`Activity Log #${entry.seq}: ${entry.unitType}${entry.unitId}`);
lines.push("─".repeat(60));
lines.push(`File: ${entry.filename}`);
lines.push(`Size: ${formatSize(entry.size)} | Age: ${formatAge(entry.mtime)}`);
lines.push(`Entries: ${summary.entryCount} | Tool calls: ${summary.toolCalls} | Errors: ${summary.errors}`);
if (summary.filesWritten.length > 0) {
lines.push("");
lines.push("Files written/edited:");
for (const f of summary.filesWritten.slice(0, 10)) {
lines.push(` ${f}`);
}
if (summary.filesWritten.length > 10) {
lines.push(` ... and ${summary.filesWritten.length - 10} more`);
}
}
if (summary.commandsRun.length > 0) {
lines.push("");
lines.push("Commands run:");
for (const c of summary.commandsRun.slice(0, 10)) {
const status = c.failed ? " FAILED" : "";
lines.push(` ${c.command}${status}`);
}
if (summary.commandsRun.length > 10) {
lines.push(` ... and ${summary.commandsRun.length - 10} more`);
}
}
if (summary.errors > 0) {
lines.push("");
lines.push(`${summary.errors} error(s) encountered during this unit.`);
}
if (summary.lastReasoning) {
lines.push("");
lines.push("Last reasoning:");
lines.push(` "${summary.lastReasoning}${summary.lastReasoning.length >= 200 ? "..." : ""}"`);
}
lines.push("");
lines.push(`Full log: ${filePath}`);
ctx.ui.notify(lines.join("\n"), "info");
}
async function handleLogsDebug(basePath: string, ctx: ExtensionCommandContext, idx?: number): Promise<void> {
const debugLogs = listDebugLogs(basePath);
if (debugLogs.length === 0) {
ctx.ui.notify(
"No debug logs found.\n\nEnable debug logging: GSD_DEBUG=1 gsd auto",
"info",
);
return;
}
if (idx === undefined) {
// List debug logs
const lines: string[] = ["Debug Logs (.gsd/debug/):", ""];
for (let i = 0; i < debugLogs.length; i++) {
const d = debugLogs[i];
lines.push(` ${i + 1}. ${d.filename} ${formatSize(d.size)} ${formatAge(d.mtime)}`);
}
lines.push("");
lines.push("View details: /gsd logs debug <#>");
ctx.ui.notify(lines.join("\n"), "info");
return;
}
// Show specific debug log
if (idx < 1 || idx > debugLogs.length) {
ctx.ui.notify(`Debug log #${idx} not found. Available: 1-${debugLogs.length}`, "warning");
return;
}
const entry = debugLogs[idx - 1];
const filePath = join(debugDir(basePath), entry.filename);
const summary = summarizeDebugLog(filePath);
const lines: string[] = [];
lines.push(`Debug Log: ${entry.filename}`);
lines.push("─".repeat(60));
lines.push(`Size: ${formatSize(entry.size)} | Age: ${formatAge(entry.mtime)}`);
lines.push(`Events: ${summary.events} | Duration: ${summary.duration} | Dispatches: ${summary.dispatches}`);
if (summary.errors.length > 0) {
lines.push("");
lines.push("Errors/failures:");
for (const e of summary.errors.slice(0, 10)) {
lines.push(` [${e.event}] ${e.message}`);
}
if (summary.errors.length > 10) {
lines.push(` ... and ${summary.errors.length - 10} more`);
}
}
lines.push("");
lines.push(`Full log: ${filePath}`);
ctx.ui.notify(lines.join("\n"), "info");
}
async function handleLogsTail(basePath: string, ctx: ExtensionCommandContext, count: number): Promise<void> {
const activities = listActivityLogs(basePath);
if (activities.length === 0) {
ctx.ui.notify("No activity logs found. Logs are created during auto-mode.", "info");
return;
}
const recent = activities.slice(-Math.max(1, Math.min(count, 20)));
const lines: string[] = [`Last ${recent.length} activity log(s):`, ""];
for (const e of recent) {
const filePath = join(activityDir(basePath), e.filename);
const summary = summarizeActivityLog(filePath);
const status = summary.errors > 0 ? `${summary.errors} err` : "ok";
lines.push(` #${e.seq} ${e.unitType} ${e.unitId}${summary.toolCalls} tools, ${status}, ${formatAge(e.mtime)}`);
}
ctx.ui.notify(lines.join("\n"), "info");
}
async function handleLogsClear(basePath: string, ctx: ExtensionCommandContext): Promise<void> {
let removedActivity = 0;
let removedDebug = 0;
// Clear activity logs older than 7 days, keep the 5 most recent
const activities = listActivityLogs(basePath);
const keepRecent = activities.slice(-5);
const keepSeqs = new Set(keepRecent.map(e => e.seq));
const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
for (const e of activities) {
if (keepSeqs.has(e.seq)) continue;
if (e.mtime.getTime() < cutoff) {
try {
unlinkSync(join(activityDir(basePath), e.filename));
removedActivity++;
} catch { /* ignore */ }
}
}
// Clear debug logs older than 3 days, keep latest 2
const debugLogs = listDebugLogs(basePath);
const keepDebug = debugLogs.slice(-2);
const keepDebugNames = new Set(keepDebug.map(d => d.filename));
const debugCutoff = Date.now() - 3 * 24 * 60 * 60 * 1000;
for (const d of debugLogs) {
if (keepDebugNames.has(d.filename)) continue;
if (d.mtime.getTime() < debugCutoff) {
try {
unlinkSync(join(debugDir(basePath), d.filename));
removedDebug++;
} catch { /* ignore */ }
}
}
if (removedActivity === 0 && removedDebug === 0) {
ctx.ui.notify("No old logs to clear.", "info");
} else {
ctx.ui.notify(
`Cleared ${removedActivity} activity log(s) and ${removedDebug} debug log(s).`,
"info",
);
}
}

View file

@ -43,6 +43,7 @@ import { handleConfig } from "./commands-config.js";
import { handleInspect } from "./commands-inspect.js";
import { handleCleanupBranches, handleCleanupSnapshots, handleSkip, handleDryRun } from "./commands-maintenance.js";
import { handleDoctor, handleSteer, handleCapture, handleTriage, handleKnowledge, handleRunHook, handleUpdate, handleSkillHealth } from "./commands-handlers.js";
import { handleLogs } from "./commands-logs.js";
// ─── Re-exports (preserve public API surface) ───────────────────────────────
export { handlePrefs, handlePrefsMode, handlePrefsWizard, ensurePreferencesFile, handleImportClaude, buildCategorySummaries, serializePreferencesToFrontmatter, yamlSafeString, configureMode } from "./commands-prefs-wizard.js";
@ -107,6 +108,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
{ cmd: "run-hook", desc: "Manually trigger a specific hook" },
{ cmd: "skill-health", desc: "Skill lifecycle dashboard" },
{ cmd: "doctor", desc: "Runtime health checks with auto-fix" },
{ cmd: "logs", desc: "Browse activity logs, debug logs, and metrics" },
{ cmd: "forensics", desc: "Examine execution logs" },
{ cmd: "init", desc: "Project init wizard — detect, configure, bootstrap .gsd/" },
{ cmd: "setup", desc: "Global setup status and configuration" },
@ -184,6 +186,18 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
.map((s) => ({ value: `setup ${s.cmd}`, label: s.cmd, description: s.desc }));
}
if (parts[0] === "logs" && parts.length <= 2) {
const subPrefix = parts[1] ?? "";
const subs = [
{ cmd: "debug", desc: "List or view debug log files" },
{ cmd: "tail", desc: "Show last N activity log summaries" },
{ cmd: "clear", desc: "Remove old activity and debug logs" },
];
return subs
.filter((s) => s.cmd.startsWith(subPrefix))
.map((s) => ({ value: `logs ${s.cmd}`, label: s.cmd, description: s.desc }));
}
if (parts[0] === "keys" && parts.length <= 2) {
const subPrefix = parts[1] ?? "";
const subs = [
@ -392,6 +406,11 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
return;
}
if (trimmed === "logs" || trimmed.startsWith("logs ")) {
await handleLogs(trimmed.replace(/^logs\s*/, "").trim(), ctx);
return;
}
if (trimmed === "forensics" || trimmed.startsWith("forensics ")) {
const { handleForensics } = await import("./forensics.js");
await handleForensics(trimmed.replace(/^forensics\s*/, "").trim(), ctx, pi);

View file

@ -0,0 +1,241 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, writeFileSync, rmSync, existsSync, utimesSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { handleLogs } from "../commands-logs.ts";
// ─── Test helpers ───────────────────────────────────────────────────────────
function createTestDir(): string {
const dir = mkdtempSync(join(tmpdir(), "gsd-logs-test-"));
mkdirSync(join(dir, ".gsd", "activity"), { recursive: true });
mkdirSync(join(dir, ".gsd", "debug"), { recursive: true });
return dir;
}
function createMockCtx(): { notifications: Array<{ msg: string; level: string }>; ui: any } {
const notifications: Array<{ msg: string; level: string }> = [];
return {
notifications,
ui: {
notify(msg: string, level: string) { notifications.push({ msg, level }); },
setStatus() {},
setWidget() {},
setFooter() {},
},
};
}
function writeActivityLog(dir: string, seq: number, unitType: string, unitId: string, entries: Record<string, unknown>[]): void {
const safeId = unitId.replace(/\//g, "-");
const filename = `${String(seq).padStart(3, "0")}-${unitType}-${safeId}.jsonl`;
const content = entries.map(e => JSON.stringify(e)).join("\n") + "\n";
writeFileSync(join(dir, ".gsd", "activity", filename), content);
}
function writeDebugLog(dir: string, name: string, entries: Record<string, unknown>[]): void {
const content = entries.map(e => JSON.stringify(e)).join("\n") + "\n";
writeFileSync(join(dir, ".gsd", "debug", name), content);
}
// ─── Tests ──────────────────────────────────────────────────────────────────
test("logs shows empty state message when no logs exist", async () => {
const dir = createTestDir();
const ctx = createMockCtx();
const origCwd = process.cwd();
process.chdir(dir);
try {
await handleLogs("", ctx as any);
assert.equal(ctx.notifications.length, 1);
assert.ok(ctx.notifications[0].msg.includes("No logs found"));
} finally {
process.chdir(origCwd);
rmSync(dir, { recursive: true, force: true });
}
});
test("logs lists activity logs", async () => {
const dir = createTestDir();
const ctx = createMockCtx();
const origCwd = process.cwd();
process.chdir(dir);
writeActivityLog(dir, 1, "execute-task", "M001/S01/T01", [
{ type: "toolCall", name: "bash", arguments: { command: "npm test" } },
{ role: "toolResult", toolCallId: "1", toolName: "bash", isError: false },
]);
writeActivityLog(dir, 2, "complete-slice", "M001/S01", [
{ role: "assistant", content: "Completing slice S01" },
]);
try {
await handleLogs("", ctx as any);
assert.equal(ctx.notifications.length, 1);
const msg = ctx.notifications[0].msg;
assert.ok(msg.includes("Activity Logs"), "should show activity logs header");
assert.ok(msg.includes("execute-task"), "should show unit type");
assert.ok(msg.includes("complete-slice"), "should show second log");
assert.ok(msg.includes("/gsd logs <#>"), "should show usage hint");
} finally {
process.chdir(origCwd);
rmSync(dir, { recursive: true, force: true });
}
});
test("logs <N> shows activity log details", async () => {
const dir = createTestDir();
const ctx = createMockCtx();
const origCwd = process.cwd();
process.chdir(dir);
writeActivityLog(dir, 1, "execute-task", "M001/S01/T01", [
{ type: "toolCall", name: "bash", arguments: { command: "npm test" } },
{ type: "toolCall", name: "write", arguments: { file_path: "/tmp/test.ts" } },
{ role: "toolResult", toolCallId: "1", toolName: "bash", isError: false },
{ role: "toolResult", toolCallId: "2", toolName: "write", isError: true },
{ role: "assistant", content: "I ran the tests and wrote a file" },
]);
try {
await handleLogs("1", ctx as any);
assert.equal(ctx.notifications.length, 1);
const msg = ctx.notifications[0].msg;
assert.ok(msg.includes("Activity Log #1"), "should show log number");
assert.ok(msg.includes("execute-task"), "should show unit type");
assert.ok(msg.includes("Tool calls: 2"), "should count tool calls");
assert.ok(msg.includes("Errors: 1"), "should count errors");
assert.ok(msg.includes("/tmp/test.ts"), "should show files written");
assert.ok(msg.includes("npm test"), "should show commands run");
} finally {
process.chdir(origCwd);
rmSync(dir, { recursive: true, force: true });
}
});
test("logs <N> shows not found for invalid seq", async () => {
const dir = createTestDir();
const ctx = createMockCtx();
const origCwd = process.cwd();
process.chdir(dir);
try {
await handleLogs("999", ctx as any);
assert.equal(ctx.notifications.length, 1);
assert.ok(ctx.notifications[0].msg.includes("not found"));
assert.equal(ctx.notifications[0].level, "warning");
} finally {
process.chdir(origCwd);
rmSync(dir, { recursive: true, force: true });
}
});
test("logs debug lists debug logs", async () => {
const dir = createTestDir();
const ctx = createMockCtx();
const origCwd = process.cwd();
process.chdir(dir);
writeDebugLog(dir, "debug-2026-03-18T10-30-00.log", [
{ ts: "2026-03-18T10:30:00Z", event: "debug-start", platform: "darwin" },
{ ts: "2026-03-18T10:35:00Z", event: "debug-summary", dispatches: 5 },
]);
try {
await handleLogs("debug", ctx as any);
assert.equal(ctx.notifications.length, 1);
const msg = ctx.notifications[0].msg;
assert.ok(msg.includes("Debug Logs"), "should show debug logs header");
assert.ok(msg.includes("debug-2026-03-18T10-30-00.log"), "should show filename");
} finally {
process.chdir(origCwd);
rmSync(dir, { recursive: true, force: true });
}
});
test("logs debug <N> shows debug log summary", async () => {
const dir = createTestDir();
const ctx = createMockCtx();
const origCwd = process.cwd();
process.chdir(dir);
writeDebugLog(dir, "debug-2026-03-18T10-30-00.log", [
{ ts: "2026-03-18T10:30:00Z", event: "debug-start", platform: "darwin" },
{ ts: "2026-03-18T10:30:05Z", event: "dispatch-error", error: "missing plan" },
{ ts: "2026-03-18T10:35:00Z", event: "debug-summary", dispatches: 5 },
]);
try {
await handleLogs("debug 1", ctx as any);
assert.equal(ctx.notifications.length, 1);
const msg = ctx.notifications[0].msg;
assert.ok(msg.includes("Debug Log:"), "should show debug log header");
assert.ok(msg.includes("Events: 3"), "should count events");
assert.ok(msg.includes("Dispatches: 5"), "should show dispatch count");
assert.ok(msg.includes("dispatch-error"), "should show errors");
} finally {
process.chdir(origCwd);
rmSync(dir, { recursive: true, force: true });
}
});
test("logs tail shows recent activity summaries", async () => {
const dir = createTestDir();
const ctx = createMockCtx();
const origCwd = process.cwd();
process.chdir(dir);
writeActivityLog(dir, 1, "execute-task", "M001/S01/T01", [
{ type: "toolCall", name: "bash", arguments: { command: "npm test" } },
]);
writeActivityLog(dir, 2, "execute-task", "M001/S01/T02", [
{ type: "toolCall", name: "bash", arguments: { command: "npm build" } },
{ role: "toolResult", toolCallId: "1", toolName: "bash", isError: true },
]);
try {
await handleLogs("tail 2", ctx as any);
assert.equal(ctx.notifications.length, 1);
const msg = ctx.notifications[0].msg;
assert.ok(msg.includes("Last 2 activity log(s)"), "should show count");
assert.ok(msg.includes("#1"), "should show first log");
assert.ok(msg.includes("#2"), "should show second log");
} finally {
process.chdir(origCwd);
rmSync(dir, { recursive: true, force: true });
}
});
test("logs clear removes old logs", async () => {
const dir = createTestDir();
const ctx = createMockCtx();
const origCwd = process.cwd();
process.chdir(dir);
// Create an old activity log (modify mtime to 10 days ago)
writeActivityLog(dir, 1, "execute-task", "M001/S01/T01", [{ type: "toolCall" }]);
const oldFile = join(dir, ".gsd", "activity", "001-execute-task-M001-S01-T01.jsonl");
const oldTime = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000);
utimesSync(oldFile, oldTime, oldTime);
// Create 6 recent activity logs so the old one is outside the "keep 5" window
for (let i = 2; i <= 7; i++) {
writeActivityLog(dir, i, "execute-task", `M001/S01/T0${i}`, [{ type: "toolCall" }]);
}
try {
await handleLogs("clear", ctx as any);
assert.equal(ctx.notifications.length, 1);
// Old log should be removed, recent ones kept
assert.ok(!existsSync(oldFile), "old log should be removed");
assert.ok(
existsSync(join(dir, ".gsd", "activity", "007-execute-task-M001-S01-T07.jsonl")),
"most recent log should be kept",
);
} finally {
process.chdir(origCwd);
rmSync(dir, { recursive: true, force: true });
}
});