mirror of https://github.com/garrytan/gstack.git
109 lines
4.6 KiB
Plaintext
Executable File
109 lines
4.6 KiB
Plaintext
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]
|
|
* [--semantic]
|
|
*
|
|
* 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.
|
|
*
|
|
* `--semantic` (with `--query`) appends an OPTIONAL "related from memory" block from
|
|
* gbrain semantic recall. It is a pure enhancement: when gbrain is off/unconfigured/
|
|
* empty it degrades silently to the reliable file results above. The reliable path
|
|
* never loads gbrain code (the semantic module is imported lazily only here).
|
|
*/
|
|
|
|
import { existsSync } from "fs";
|
|
import {
|
|
decisionPaths,
|
|
readSnapshot,
|
|
rebuildSnapshot,
|
|
readEvents,
|
|
filterByScope,
|
|
datamark,
|
|
type ActiveDecision,
|
|
} from "../lib/gstack-decision";
|
|
import { resolveSlug, gitBranch, flagValue } from "../lib/bin-context";
|
|
|
|
const HERE = import.meta.dir;
|
|
const args = process.argv.slice(2);
|
|
|
|
const slug = resolveSlug(`${HERE}/gstack-slug`);
|
|
const paths = decisionPaths(slug);
|
|
const queryRaw = flagValue(args, "--query");
|
|
const query = queryRaw?.toLowerCase();
|
|
const scope = flagValue(args, "--scope");
|
|
const branch = flagValue(args, "--branch") ?? gitBranch();
|
|
const issue = flagValue(args, "--issue");
|
|
const recentRaw = flagValue(args, "--recent");
|
|
const recent = recentRaw ? parseInt(recentRaw, 10) : undefined;
|
|
const showAll = args.includes("--all");
|
|
const asJson = args.includes("--json");
|
|
const semantic = args.includes("--semantic");
|
|
|
|
let rows: ActiveDecision[];
|
|
if (showAll) {
|
|
// --all includes SUPERSEDED decisions (history), but NEVER redacted ones — a redact
|
|
// is an expunge, so it must remove the text from every read path, not just active.
|
|
const events = readEvents(paths);
|
|
const redacted = new Set(
|
|
events.filter((e) => e.kind === "redact" && e.supersedes).map((e) => e.supersedes as string),
|
|
);
|
|
rows = events.filter((e): e is ActiveDecision => e.kind === "decide" && !redacted.has(e.id));
|
|
} 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) {
|
|
// --json stays reliable-only (semantic recall is a human-facing supplement).
|
|
console.log(JSON.stringify(rows));
|
|
process.exit(0);
|
|
}
|
|
|
|
for (const d of rows) {
|
|
// Datamark all stored free-text (decision, rationale, branch/issue) — it lands in
|
|
// agent context via Context Recovery, so treat it as DATA, not instructions.
|
|
const branchTag = d.branch ? `:${datamark(d.branch)}` : "";
|
|
const issueTag = d.issue ? `:${datamark(d.issue)}` : "";
|
|
const scopeTag = d.scope === "repo" ? "" : ` [${d.scope}${branchTag}${issueTag}]`;
|
|
console.log(`- ${datamark(d.decision ?? "")}${scopeTag} (${d.source}, ${d.date.slice(0, 10)})`);
|
|
if (d.rationale) console.log(` why: ${datamark(d.rationale)}`);
|
|
}
|
|
|
|
// OPTIONAL gbrain enhancement. Lazy import so the reliable path above never loads
|
|
// gbrain code. Degrades silently: null (gbrain off) or [] (nothing found) leaves the
|
|
// reliable results above as the answer.
|
|
if (semantic && queryRaw) {
|
|
const { semanticRecall } = await import("../lib/gstack-decision-semantic");
|
|
const hits = semanticRecall(queryRaw);
|
|
if (hits && hits.length) {
|
|
console.log("\nRelated from memory (gbrain semantic recall):");
|
|
for (const h of hits) {
|
|
// gbrain hits are EXTERNAL corpus content — datamark slug + snippet too so they
|
|
// can't spoof role markers / fences when printed into agent context.
|
|
const snip = datamark(h.snippet.length > 100 ? `${h.snippet.slice(0, 100)}…` : h.snippet);
|
|
console.log(` [${h.score.toFixed(2)}] ${datamark(h.slug)}: ${snip}`);
|
|
}
|
|
}
|
|
}
|