#!/usr/bin/env bun /** * gstack-decision-search — read active decisions (the curated "what did we decide" view). * * Usage: * gstack-decision-search [--query KW] [--scope repo|branch|issue] * [--branch B] [--issue I] [--recent N] [--all] [--json] * * Reads the BOUNDED active snapshot (decisions.active.json) — O(active), not a full * history scan — and rebuilds it from the event log if missing. Scope-filtered to the * current branch/issue context (recency != relevance). NON-INTERACTIVE. `--all` shows * superseded decisions too (from the full log). Exit 0 silently when there are none. */ import { existsSync } from "fs"; import { spawnSync } from "child_process"; import { decisionPaths, readSnapshot, rebuildSnapshot, readEvents, filterByScope, type ActiveDecision, } from "../lib/gstack-decision"; const HERE = import.meta.dir; const args = process.argv.slice(2); function resolveSlug(): string { const r = spawnSync(`${HERE}/gstack-slug`, { encoding: "utf-8" }); const m = (r.stdout || "").match(/^SLUG=(.+)$/m); return m ? m[1].trim() : "unknown"; } function gitBranch(): string | undefined { const r = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf-8" }); const b = (r.stdout || "").trim(); return b && b !== "HEAD" ? b : undefined; } function flagValue(name: string): string | undefined { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; } const slug = resolveSlug(); const paths = decisionPaths(slug); const query = flagValue("--query")?.toLowerCase(); const scope = flagValue("--scope"); const branch = flagValue("--branch") ?? gitBranch(); const issue = flagValue("--issue"); const recent = flagValue("--recent") ? parseInt(flagValue("--recent") as string, 10) : undefined; const showAll = args.includes("--all"); const asJson = args.includes("--json"); let rows: ActiveDecision[]; if (showAll) { // include superseded: every decide event from the full log (no active filter) rows = readEvents(paths).filter((e): e is ActiveDecision => e.kind === "decide"); } else { rows = readSnapshot(paths); // Rebuild only when a snapshot is absent but a log exists (don't write a snapshot // into a nonexistent store on an empty read — just return nothing). if (!rows.length && existsSync(paths.log)) rows = rebuildSnapshot(paths); } rows = filterByScope(rows, { branch, issue }); if (scope) rows = rows.filter((d) => d.scope === scope); if (query) { rows = rows.filter((d) => [d.decision, d.rationale, d.alternatives_considered] .filter((s): s is string => typeof s === "string") .some((s) => s.toLowerCase().includes(query)), ); } rows.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0)); // newest first if (recent && recent > 0) rows = rows.slice(0, recent); if (asJson) { console.log(JSON.stringify(rows)); process.exit(0); } if (!rows.length) process.exit(0); // silent when nothing relevant for (const d of rows) { const scopeTag = d.scope === "repo" ? "" : ` [${d.scope}${d.branch ? `:${d.branch}` : ""}${d.issue ? `:${d.issue}` : ""}]`; console.log(`- ${d.decision}${scopeTag} (${d.source}, ${d.date.slice(0, 10)})`); if (d.rationale) console.log(` why: ${d.rationale}`); }