Files
gstack/bin/gstack-decision-log
T
Garry Tanandcapy-ai[bot] b51925e3bc Rebuild the decision snapshot from the merged log when two machines' syncs conflict (#4174) (#4220)
* Rebuild the decision snapshot from the merged log when two machines' syncs conflict (#4174)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Keep redacted decisions out of a rebuilt decision snapshot

The jsonl-append merge driver takes the union of base, ours and theirs, so a
log that lost lines on one side (--compact expunging a redacted decision)
would get them back. The snapshot rebuild now runs only when every base line
of the log is still on both sides; otherwise the merge aborts as before.
A conflicted snapshot when local edits are put back after the merge is now
rebuilt from the working-tree log instead of left at the merged version.

Review-Finding: p1/red-team#1 reader=red-team
Review-Finding: p1/red-team#2 reader=red-team
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Put the stash recovery path back to how main handles it

Rebuilding a conflicted decision snapshot after stash pop made a decision
that another machine redacted and compacted away consistent and publishable,
because stash pop merges the log as a union. Without the rebuild the
mismatched log and snapshot stay held, as on main. The redaction guard on
the merge-retry rebuild stays. A new test shows the brought-back decision
stays held and never reaches the remote.

Review-Finding: p2/outside-voice#1 reader=outside-voice
Review-Finding: p2/adversarial-native#1 reader=adversarial-native
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-09 16:45:48 +00:00

177 lines
6.8 KiB
TypeScript
Executable File

#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* gstack-decision-log — append a durable decision (or supersede/redact/compact it).
*
* Usage:
* gstack-decision-log --help
* 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
* gstack-decision-log --rebuild-snapshot <project-slug>
*
* 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 { dirname } from "path";
import { existsSync } from "fs";
import { mkdirpSync } from "../lib/fs-utils";
import { spawnSync } from "child_process";
import {
DECISION_SCOPES,
DECISION_SOURCES,
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 usage = `Usage:
gstack-decision-log '<JSON>'
gstack-decision-log --supersede <decision-id> ['<replacement JSON>']
gstack-decision-log --redact <decision-id>
gstack-decision-log --compact
gstack-decision-log --rebuild-snapshot <project-slug>
gstack-decision-log --help
Decision JSON:
decision: nonempty string (required)
scope: ${DECISION_SCOPES.join("|")} (default: repo)
source: ${DECISION_SOURCES.join("|")} (default: agent)
rationale, alternatives_considered: optional explanatory text
branch: branch name; inferred from git for scope=branch when omitted
issue: issue identifier for issue-scoped retrieval
confidence: optional integer 1-10
id, date, session: optional record metadata; id/date are generated when omitted
Only these payload fields are stored; skill and tags are not record fields.
Pass JSON as one argument, not stdin. A quoted heredoc preserves apostrophes:
payload=$(cat <<'JSON'
{"decision":"Keep the caller's acceptance rule","rationale":"The application owns the threshold","scope":"repo","source":"user"}
JSON
)
gstack-decision-log "$payload"
Successful writes print the decision id. Invalid payloads exit 1 without a record.
Supersede retires the old decision and optionally records a validated replacement.
Redact accepts no replacement; compact removes redacted history and archives superseded records.
Rebuild-snapshot rewrites one project's decisions.active.json from its decisions.jsonl
(gstack-brain-sync runs it when a merge of two machines' snapshots conflicts).
Help prints this schema and exits without resolving the project or writing state.
`;
// Help and empty-input usage must not create slug caches, stores or sync work.
if (args.includes("--help")) {
process.stdout.write(usage);
process.exit(0);
}
if (args.every((arg) => !arg.trim())) {
process.stderr.write(usage);
process.exit(1);
}
if (args.includes("--rebuild-snapshot")) {
const target = flagValue(args, "--rebuild-snapshot");
const targetPaths = target && /^[^/\\]+$/.test(target) && target !== "." && target !== ".." ? decisionPaths(target) : undefined;
if (!targetPaths || !existsSync(targetPaths.log)) {
process.stderr.write("gstack-decision-log: --rebuild-snapshot needs the slug of a project that has a decisions.jsonl\n");
process.exit(1);
}
rebuildSnapshot(targetPaths);
process.exit(0);
}
const slug = resolveSlug(`${HERE}/gstack-slug`);
const paths = decisionPaths(slug);
// mkdirpSync, not bare mkdirSync: bun on Windows throws EEXIST from a
// recursive mkdir on an existing dir (#2635), and this runs on every log call.
mkdirpSync(dirname(paths.log));
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);