Files
gstack/bin/gstack-decision-search
T
Garry Tan a52e033576 feat(decision): gstack-decision-log + gstack-decision-search bins (non-interactive)
Two bins mirroring gstack-learnings-* (D3A). log writes decide/--supersede/--redact/
--compact events + refreshes the bounded snapshot + enqueues for cross-machine sync;
search reads the O(active) snapshot, scope-filtered to current branch, newest-first,
--all to include superseded, --json for machines. Empty store returns silently
(no snapshot write on an empty read).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 09:10:27 -07:00

88 lines
3.2 KiB
TypeScript
Executable File

#!/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}`);
}