mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 22:48:57 +02:00
The supersede/redact branch appended the retirement event and exited before the JSON argument was ever read — a user recording a reversal WITH its replacement lost the replacement, and the payload finder's first-non-flag-arg predicate would have mistaken the target id for JSON anyway. Payloads are now identified by their leading brace, validated BEFORE any write, and appended FIRST (retirement second), so the only visible interleaving under a crash is both-active — recoverable, never lost. The replacement carries supersedes:<old-id> provenance. Bare --supersede <id> (the documented reversal-without-replacement) stays legal; --redact with a payload now refuses instead of dropping it. Ported from time-attack/gstack (GStack 2), tests included. Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
115 lines
4.1 KiB
TypeScript
Executable File
115 lines
4.1 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);
|
|
if (r.skipped) {
|
|
console.log("compact skipped: a concurrent write/compact is in progress; log left intact — re-run");
|
|
process.exit(0);
|
|
}
|
|
console.log(`compacted: ${r.activeCount} active, ${r.archivedCount} archived, ${r.expungedCount} expunged`);
|
|
enqueue();
|
|
process.exit(0);
|
|
}
|
|
|
|
// The payload is identified by its leading `{`, not by "first non-flag arg" — a
|
|
// `--supersede <id> '{...}'` call would otherwise mistake the target id for the payload.
|
|
const jsonArg = args.find((a) => a.trimStart().startsWith("{"));
|
|
|
|
/** Parse + validate a decision payload. Exits 1 (nothing persisted) when it's bad. */
|
|
function validPayload(raw: string): DecisionEvent {
|
|
let obj: Partial<DecisionEvent>;
|
|
try {
|
|
obj = JSON.parse(raw);
|
|
} 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);
|
|
}
|
|
return res.event;
|
|
}
|
|
|
|
const supersedeId = flagValue(args, "--supersede");
|
|
const redactId = flagValue(args, "--redact");
|
|
if (supersedeId || redactId) {
|
|
const kind = supersedeId ? "supersede" : "redact";
|
|
const targetId = (supersedeId || redactId) as string;
|
|
if (targetId.trimStart().startsWith("{")) {
|
|
process.stderr.write(`gstack-decision-log: --${kind} needs the target decision id before the replacement JSON\n`);
|
|
process.exit(1);
|
|
}
|
|
if (kind === "redact" && jsonArg) {
|
|
process.stderr.write(
|
|
"gstack-decision-log: --redact expunges and takes no replacement; log the replacement in its own call so it isn't dropped\n",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
// Validate the replacement BEFORE anything is written, then append it FIRST and
|
|
// retire the old one SECOND. Appends are individually atomic, so the only visible
|
|
// interleaving is "both active" (recoverable); the reverse order could retire the
|
|
// old decision and lose the replacement the user was recording.
|
|
const replacement = jsonArg ? { ...validPayload(jsonArg), supersedes: targetId } : undefined;
|
|
if (replacement) appendEvent(paths, replacement);
|
|
appendEvent(paths, makeRefEvent(kind, targetId, { source: "agent" }));
|
|
rebuildSnapshot(paths);
|
|
enqueue();
|
|
console.log(replacement ? `${kind}: ${targetId} -> ${replacement.id}` : `${kind}: ${targetId}`);
|
|
process.exit(0);
|
|
}
|
|
|
|
if (!jsonArg) {
|
|
process.stderr.write(
|
|
"gstack-decision-log: provide a JSON decision, or --supersede/--redact <id>, or --compact\n",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
const event = validPayload(jsonArg);
|
|
appendEvent(paths, event);
|
|
rebuildSnapshot(paths);
|
|
enqueue();
|
|
console.log(event.id);
|