From 55f306cd7d91a86af0c901a037d9a6054c8408cf Mon Sep 17 00:00:00 2001 From: abhinav-phi Date: Wed, 9 Sep 2026 05:32:12 +0530 Subject: [PATCH 1/2] feat(cli): add timeline --format md Renders the filtered timeline as a Markdown table (Date, Type, Event, Files) suitable for piping into reports and standup notes. Pipes and line breaks inside a message are escaped so it cannot break the table; an empty log emits an italic placeholder. The default terminal output is untouched when --format is absent. Resolves #55 --- src/cli.ts | 1 + src/events.ts | 33 +++++++++++++++++++++++++++++++++ test/events.test.ts | 28 ++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+) diff --git a/src/cli.ts b/src/cli.ts index 042e6e89..1240c80e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -897,6 +897,7 @@ program .command("timeline") .description("Read bounded recent project notes (latest 8 MiB / 10,000 log lines)") .option("--json", "Output events as JSON") + .option("--format ", "Output format: md for a Markdown table (piped into reports)") .option("--since ", "Filter from YYYY-MM-DD or relative Nd, e.g. 30d") .option("--type ", "Filter by event type: decision, note, risk, todo") .option("--query ", "Case-insensitive literal text in the message (max 256 UTF-8 bytes)") diff --git a/src/events.ts b/src/events.ts index 9fae8161..2b6b58fa 100644 --- a/src/events.ts +++ b/src/events.ts @@ -58,6 +58,8 @@ export interface LogOpts { export interface TimelineOpts { json?: boolean; + /** `"md"` renders a Markdown table instead of the terminal output (#55). */ + format?: string; since?: string; kind?: string; limit?: number; @@ -143,6 +145,11 @@ export async function runTimeline(config: MexConfig, opts: TimelineOpts = {}): P return; } + if (opts.format === "md") { + printTimelineMarkdown(selected, truncated, sourceTruncated); + return; + } + if (selected.length === 0 && !truncated) { console.log(chalk.dim("No events found.")); } @@ -155,6 +162,32 @@ export async function runTimeline(config: MexConfig, opts: TimelineOpts = {}): P if (sourceTruncated) console.log(chalk.dim("Searched only the latest 8 MiB / 10,000 non-empty log lines; older history was not scanned.")); } +/** + * Markdown rendering for `timeline --format md` — valid inside reports and + * standup notes. Pipes and line breaks are escaped so a message cannot break + * the table; omission notes mirror the terminal output in italics. The default + * terminal rendering is untouched (#55). + */ +function printTimelineMarkdown( + selected: EventEntry[], + truncated: boolean, + sourceTruncated: boolean, +): void { + if (selected.length === 0) { + console.log("_No events found._"); + return; + } + console.log("| Date | Type | Event | Files |"); + console.log("|---|---|---|---|"); + for (const e of selected) { + const message = e.message.replace(/\|/g, "\\|").replace(/\r?\n/g, " "); + const files = e.files.length ? e.files.map((f) => `\`${f}\``).join(", ") : "—"; + console.log(`| ${e.timestamp.slice(0, 10)} | ${e.kind} | ${message} | ${files} |`); + } + if (truncated) console.log("_Some matching events were omitted by the entry or output limit; narrow the filters._"); + if (sourceTruncated) console.log("_Searched only the latest 8 MiB / 10,000 non-empty log lines; older history was not scanned._"); +} + export function readEvents(config: MexConfig): EventEntry[] { return readEventLog(config).entries; } diff --git a/test/events.test.ts b/test/events.test.ts index c6e756a3..8a9ca3aa 100644 --- a/test/events.test.ts +++ b/test/events.test.ts @@ -188,4 +188,32 @@ describe("events", () => { await runTimeline(config, { json: true }); expect(spy.mock.calls.at(-1)?.[0]).toContain('"events"'); }); + + it("timeline --format md emits a valid Markdown table (#55)", async () => { + await runLog(config, "chose | the | bounded resolver", { kind: "decision", files: ["ROUTER.md"] }); + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); + await runTimeline(config, { format: "md" }); + const lines = spy.mock.calls.map((call) => String(call[0])); + expect(lines[0]).toBe("| Date | Type | Event | Files |"); + expect(lines[1]).toBe("|---|---|---|---|"); + const row = lines[2]!; + expect(row).toMatch(/^\| \d{4}-\d{2}-\d{2} \| decision \| /); + expect(row).toContain("chose \\| the \\| bounded resolver"); + expect(row).toContain("`ROUTER.md`"); + }); + + it("timeline --format md emits a placeholder for an empty log", async () => { + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); + await runTimeline(config, { format: "md" }); + expect(spy.mock.calls.at(-1)?.[0]).toBe("_No events found._"); + }); + + it("timeline default output is unchanged when --format is absent", async () => { + await runLog(config, "plain note", { kind: "note" }); + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); + await runTimeline(config, {}); + const rendered = spy.mock.calls.map((call) => String(call[0])).join("\n"); + expect(rendered).toContain("plain note"); + expect(rendered).not.toContain("|---"); + }); }); From 8e3fadcf0b653d8b3b263d1580d348b6eaddfeec Mon Sep 17 00:00:00 2001 From: abhinav-phi Date: Wed, 9 Sep 2026 06:13:55 +0530 Subject: [PATCH 2/2] ci: re-trigger check after a vitest worker RPC flake