Files
gstack/bin/gstack-decision-log
T
Garry Tan 55e7ed9fec fix: pre-landing review fixes (datamark, DRY, compact, coverage)
Addresses the pre-landing review findings (all INFORMATIONAL, no criticals):
- security: datamark resurfaced decision text at the render boundary
  (lib/gstack-decision.ts datamark() — neutralizes code fences, --- banners,
  <|role|>/</system> markers, control chars, newlines). Applied in
  gstack-decision-search human output so stored text can't masquerade as
  instructions in Context Recovery (codex hardening #3 / AC #7). --json stays raw.
- DRY: extract resolveSlug/gitBranch/flagValue to lib/bin-context.ts; both
  decision bins use it instead of duplicating the helpers.
- compact(): batch the archive append (one write, not N) and shrink the
  mid-compact crash window; simplify the opaque branch/issue ternary.
- coverage: learnings-log injection rejection (D2A wiring), search --recent/
  --scope + NaN-safe --recent, datamark-applied, unparseable lock body,
  compact-empty, corrupt-snapshot degrade.

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

86 lines
2.7 KiB
TypeScript
Executable File

#!/usr/bin/env bun
/**
* gstack-decision-log — append a durable decision (or supersede/redact/compact it).
*
* Usage:
* gstack-decision-log '{"decision":"...","rationale":"...","scope":"repo","source":"user"}'
* gstack-decision-log --supersede <decision-id>
* gstack-decision-log --redact <decision-id>
* gstack-decision-log --compact
*
* Event-sourced (lib/gstack-decision): every call appends an event and refreshes the
* bounded active snapshot. NON-INTERACTIVE — never prompts (agents/skills call this;
* a prompt would hang them). Validation + injection + HIGH-secret rejection happen in
* validateDecide; a rejected decision exits 1 with a message, nothing persisted.
*/
import { mkdirSync } from "fs";
import { dirname } from "path";
import { spawnSync } from "child_process";
import {
decisionPaths,
validateDecide,
makeRefEvent,
appendEvent,
rebuildSnapshot,
compact,
type DecisionEvent,
} 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);
mkdirSync(dirname(paths.log), { recursive: true });
function enqueue(): void {
// Fire-and-forget cross-machine sync (no-op when artifacts_sync is off).
spawnSync(`${HERE}/gstack-brain-enqueue`, [`projects/${slug}/decisions.jsonl`], { stdio: "ignore" });
}
if (args.includes("--compact")) {
const r = compact(paths);
console.log(`compacted: ${r.activeCount} active, ${r.archivedCount} archived, ${r.expungedCount} expunged`);
enqueue();
process.exit(0);
}
const supersedeId = flagValue(args, "--supersede");
const redactId = flagValue(args, "--redact");
if (supersedeId || redactId) {
const kind = supersedeId ? "supersede" : "redact";
const targetId = (supersedeId || redactId) as string;
appendEvent(paths, makeRefEvent(kind, targetId, { source: "agent" }));
rebuildSnapshot(paths);
enqueue();
console.log(`${kind}: ${targetId}`);
process.exit(0);
}
const jsonArg = args.find((a) => !a.startsWith("--"));
if (!jsonArg) {
process.stderr.write(
"gstack-decision-log: provide a JSON decision, or --supersede/--redact <id>, or --compact\n",
);
process.exit(1);
}
let obj: Partial<DecisionEvent>;
try {
obj = JSON.parse(jsonArg);
} catch {
process.stderr.write("gstack-decision-log: invalid JSON\n");
process.exit(1);
}
if (obj.scope === "branch" && !obj.branch) obj.branch = gitBranch();
const res = validateDecide(obj);
if (!res.ok) {
process.stderr.write(`gstack-decision-log: ${res.error}\n`);
process.exit(1);
}
appendEvent(paths, res.event);
rebuildSnapshot(paths);
enqueue();
console.log(res.event.id);