mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-17 15:20:11 +02:00
Merge remote-tracking branch 'origin/main' into garrytan/triage-open-issues
# Conflicts: # CHANGELOG.md # VERSION # package.json
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* bin-context — tiny shared helpers for non-interactive gstack bins that need the
|
||||
* project slug, current branch, and argv flags. Extracted from the decision bins
|
||||
* (gstack-decision-log / gstack-decision-search) so the slug/branch/flag plumbing
|
||||
* lives in one audited place instead of being copy-pasted per bin.
|
||||
*/
|
||||
|
||||
import { spawnSync } from "child_process";
|
||||
|
||||
/** Resolve the project slug via the `gstack-slug` helper (parses `SLUG=...`). */
|
||||
export function resolveSlug(slugBinPath: string): string {
|
||||
const r = spawnSync(slugBinPath, { encoding: "utf-8" });
|
||||
const m = (r.stdout || "").match(/^SLUG=(.+)$/m);
|
||||
return m ? m[1].trim() : "unknown";
|
||||
}
|
||||
|
||||
/** Current git branch, or undefined on detached HEAD / outside a repo. */
|
||||
export 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;
|
||||
}
|
||||
|
||||
/** The value following `--flag` in argv, or undefined if absent. */
|
||||
export function flagValue(args: string[], name: string): string | undefined {
|
||||
const i = args.indexOf(name);
|
||||
return i >= 0 ? args[i + 1] : undefined;
|
||||
}
|
||||
+43
-2
@@ -29,7 +29,7 @@
|
||||
*/
|
||||
|
||||
import { spawnSync } from "child_process";
|
||||
import { existsSync, realpathSync } from "fs";
|
||||
import { existsSync, realpathSync, readFileSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import { join, resolve, sep } from "path";
|
||||
import { execGbrainJson, execGbrainText, NEEDS_SHELL_ON_WINDOWS } from "./gbrain-exec";
|
||||
@@ -92,7 +92,20 @@ export function detectAutopilot(
|
||||
join(homedir(), ".gbrain", "autopilot.pid"),
|
||||
];
|
||||
for (const lp of lockPaths) {
|
||||
if (existsSync(lp)) return { active: true, signal: `lock:${lp}` };
|
||||
if (!existsSync(lp)) continue;
|
||||
// A lock FILE alone is not proof of life — a crashed daemon leaves a stale
|
||||
// lock that would otherwise wedge every sync forever (observed: a dead pid
|
||||
// refused --full indefinitely). Read the holder pid and check liveness.
|
||||
const pid = readLockPid(lp);
|
||||
if (pid === null) {
|
||||
// Can't introspect (no parseable pid) → stay conservative: treat as active.
|
||||
return { active: true, signal: `lock:${lp}` };
|
||||
}
|
||||
if (isPidAlive(pid)) {
|
||||
return { active: true, signal: `lock:${lp} (pid ${pid})` };
|
||||
}
|
||||
// Stale lock (holder pid is dead): ignore this signal, keep checking. Pure
|
||||
// decision function — we do NOT delete the file here; the caller may clean it.
|
||||
}
|
||||
// Primary signal: a live `gbrain autopilot` process.
|
||||
const running = (probe.processRunning ?? defaultProcessRunning)();
|
||||
@@ -100,6 +113,34 @@ export function detectAutopilot(
|
||||
return { active: false, signal: null };
|
||||
}
|
||||
|
||||
/** Read the holder pid from a lock/pid file. Returns null if no integer pid is present. */
|
||||
function readLockPid(lockPath: string): number | null {
|
||||
try {
|
||||
const raw = readFileSync(lockPath, "utf-8").trim();
|
||||
// Files seen: a bare pid ("65495"), or JSON like {"pid":65495,...}.
|
||||
const m = raw.match(/"pid"\s*:\s*(\d+)/) ?? raw.match(/^(\d+)$/);
|
||||
if (!m) return null;
|
||||
const pid = Number.parseInt(m[1], 10);
|
||||
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liveness via signal 0: no signal sent, just an existence/permission check.
|
||||
* ESRCH → dead; EPERM → alive but owned by another user. Cross-host pids are
|
||||
* meaningless, but the autopilot lock is same-host by construction.
|
||||
*/
|
||||
function isPidAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return (err as NodeJS.ErrnoException).code === "EPERM";
|
||||
}
|
||||
}
|
||||
|
||||
function defaultProcessRunning(): boolean {
|
||||
// No reliable pgrep on Windows; rely on the lock-file signal there.
|
||||
if (process.platform === "win32") return false;
|
||||
|
||||
+58
-1
@@ -11,7 +11,7 @@
|
||||
|
||||
import { execFileSync, spawnSync } from "child_process";
|
||||
import { withErrorContext } from "./gstack-memory-helpers";
|
||||
import { NEEDS_SHELL_ON_WINDOWS } from "./gbrain-exec";
|
||||
import { execGbrainJson, NEEDS_SHELL_ON_WINDOWS } from "./gbrain-exec";
|
||||
|
||||
export interface SourceState {
|
||||
/** "absent" — id not registered. "match" — id at expected path. "drift" — id at different path. */
|
||||
@@ -217,3 +217,60 @@ export function sourcePageCount(id: string, env?: NodeJS.ProcessEnv): number | n
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a source's call graph has been built.
|
||||
*
|
||||
* "completed" — `gbrain dream` has run a full maintenance cycle, so the
|
||||
* brain-global `resolve_symbol_edges` phase populated this
|
||||
* source's call graph (`gbrain code-callers`/`code-callees`
|
||||
* return edges).
|
||||
* "never" — a cycle has provably NOT completed for this source.
|
||||
* "unknown" — doctor is unavailable, unparseable, or reports a failure
|
||||
* that doesn't name this source. Callers MUST treat unknown
|
||||
* conservatively (the orchestrator skips auto-dream and WARNs
|
||||
* rather than launch a ~35-min cycle on a flaky-doctor signal —
|
||||
* see the `gbrain-doctor-overstrict` learning).
|
||||
*/
|
||||
export type CycleStatus = "completed" | "never" | "unknown";
|
||||
|
||||
interface DoctorCheck {
|
||||
name?: string;
|
||||
status?: string;
|
||||
message?: string;
|
||||
}
|
||||
interface DoctorReport {
|
||||
checks?: DoctorCheck[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read `gbrain doctor --json --fast` and decide whether <sourceId>'s call
|
||||
* graph is built, by inspecting the `cycle_freshness` check.
|
||||
*
|
||||
* Decision table (cycle_freshness.status / message):
|
||||
* - ok → "completed"
|
||||
* - fail|warn AND message names <sourceId> → "never"
|
||||
* - fail|warn AND message omits <sourceId> → "unknown" (a real failure
|
||||
* about OTHER sources must not be silently read as completed for us)
|
||||
* - check absent / doctor null / other status → "unknown"
|
||||
*
|
||||
* `sourceId` is matched as a LITERAL substring (not a regex) so an id with
|
||||
* regex metacharacters can never misfire. Routes through `execGbrainJson` so
|
||||
* DATABASE_URL is seeded from gbrain's config (consistent with every other
|
||||
* gstack-side gbrain call). `env` is the caller's base env (tests inject a
|
||||
* shim on PATH).
|
||||
*/
|
||||
export function cycleCompleted(sourceId: string, env?: NodeJS.ProcessEnv): CycleStatus {
|
||||
const report = execGbrainJson<DoctorReport>(["doctor", "--json", "--fast"], { baseEnv: env });
|
||||
if (!report || !Array.isArray(report.checks)) return "unknown";
|
||||
|
||||
const check = report.checks.find((c) => c.name === "cycle_freshness");
|
||||
if (!check) return "unknown";
|
||||
|
||||
if (check.status === "ok") return "completed";
|
||||
if (check.status === "fail" || check.status === "warn") {
|
||||
const msg = check.message || "";
|
||||
return msg.includes(sourceId) ? "never" : "unknown";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* gstack-decision-semantic — OPTIONAL gbrain enhancement for decision resurfacing.
|
||||
*
|
||||
* This is the ONLY decision module that touches gbrain. The reliable core
|
||||
* (lib/gstack-decision.ts) has zero gbrain imports and works with gbrain OFF; this
|
||||
* module is loaded lazily by `gstack-decision-search` only on `--semantic`, and every
|
||||
* path degrades to `null` (caller shows the reliable file results) when gbrain is
|
||||
* absent, unconfigured, times out, or returns nothing. It NEVER throws and NEVER
|
||||
* hangs (10s spawn timeout). We do not wire core function to this — gbrain is an
|
||||
* enhancement, never a dependency (the code-search lesson).
|
||||
*
|
||||
* Surface reality (verified against gbrain 0.42.x, not guessed):
|
||||
* - `gbrain search "<q>"` prints TEXT lines `[score] slug -- snippet`, NOT JSON
|
||||
* (so we parse the text surface; execGbrainJson would always null here).
|
||||
* - The curated-memory source is the one whose local_path is the gstack brain
|
||||
* worktree (`~/.gstack-brain-worktree`), id `default` by convention — NOT a
|
||||
* `gstack-brain-<user>` id. Scoping search to it keeps code/doc corpora out.
|
||||
*/
|
||||
|
||||
import { spawnGbrain } from "./gbrain-exec";
|
||||
import { parseSourcesList } from "./gbrain-sources";
|
||||
|
||||
const TIMEOUT_MS = 10_000;
|
||||
const BRAIN_WORKTREE_SUFFIX = ".gstack-brain-worktree";
|
||||
|
||||
export interface SemanticHit {
|
||||
score: number;
|
||||
slug: string;
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the curated-memory source id (the gstack brain worktree). Returns null
|
||||
* when gbrain is down/unparseable OR no worktree-backed source is registered — the
|
||||
* caller then searches unscoped (best-effort) rather than failing.
|
||||
*/
|
||||
export function resolveMemorySourceId(env?: NodeJS.ProcessEnv): string | null {
|
||||
const r = spawnGbrain(["sources", "list", "--json"], { baseEnv: env, timeout: TIMEOUT_MS });
|
||||
if (r.status !== 0) return null;
|
||||
let rows;
|
||||
try {
|
||||
rows = parseSourcesList(JSON.parse(r.stdout || "null"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const atWorktree = rows.filter(
|
||||
(s) => typeof s.local_path === "string" && s.local_path.endsWith(BRAIN_WORKTREE_SUFFIX),
|
||||
);
|
||||
const pick = atWorktree.find((s) => s.id === "default") ?? atWorktree[0];
|
||||
return pick?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse gbrain search's text output into scored hits. Lines look like:
|
||||
* `[0.4361] slug -- snippet text...`
|
||||
* Non-matching lines (banners, blanks) are skipped. Exported for deterministic
|
||||
* unit testing of the parser without a live gbrain.
|
||||
*/
|
||||
export function parseSearchHits(stdout: string, minScore: number, limit: number): SemanticHit[] {
|
||||
const hits: SemanticHit[] = [];
|
||||
for (const line of stdout.split("\n")) {
|
||||
const m = line.match(/^\[([\d.]+)\]\s+(\S+)\s+--\s+(.*)$/);
|
||||
if (!m) continue;
|
||||
const score = parseFloat(m[1]);
|
||||
if (!Number.isFinite(score) || score < minScore) continue;
|
||||
hits.push({ score, slug: m[2], snippet: m[3].trim() });
|
||||
}
|
||||
return hits.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Semantic recall over the curated-memory source. Returns parsed hits, or `null`
|
||||
* when gbrain is unavailable / errors (caller MUST degrade to the reliable file
|
||||
* results on null). An empty array means gbrain ran but found nothing relevant
|
||||
* (e.g. memory not synced yet) — also honest, distinct from null. Never throws,
|
||||
* never hangs.
|
||||
*/
|
||||
export function semanticRecall(
|
||||
query: string,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
minScore = 0.1,
|
||||
limit = 3,
|
||||
): SemanticHit[] | null {
|
||||
if (!query.trim()) return null;
|
||||
// Require the curated-memory source. If it's absent (gbrain down OR no worktree-backed
|
||||
// source), degrade to null rather than searching UNSCOPED — an unscoped search pulls
|
||||
// code/doc corpora that would be mislabeled as "related decisions" (Codex finding).
|
||||
const sourceId = resolveMemorySourceId(env);
|
||||
if (!sourceId) return null;
|
||||
const r = spawnGbrain(["search", query, "--source", sourceId], { baseEnv: env, timeout: TIMEOUT_MS });
|
||||
if (r.status !== 0) return null; // gbrain down / not on PATH / errored → degrade
|
||||
return parseSearchHits(r.stdout || "", minScore, limit);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* gstack-decision — event-sourced institutional decision memory.
|
||||
*
|
||||
* decisions.jsonl is an APPEND-ONLY EVENT LOG (not mutable rows): `decide`,
|
||||
* `supersede`, and `redact` events. "Active" is COMPUTED — a `decide` whose id is
|
||||
* not later referenced by a `supersede`/`redact`. This is the eng-review event-
|
||||
* sourcing decision (a mutable `status` field would contradict append-only).
|
||||
*
|
||||
* Built on lib/jsonl-store.ts (shared injection-reject + atomic append + tolerant
|
||||
* read). Free-text fields are injection-checked AND redact-scanned on write
|
||||
* (HIGH-tier secret → reject), so a secret never silently persists and resurfaced
|
||||
* text can't carry instructions. gbrain is never required — this is the reliable
|
||||
* file-only core; semantic recall is a later, optional enhancement.
|
||||
*/
|
||||
|
||||
import { join } from "path";
|
||||
import { homedir } from "os";
|
||||
import { randomUUID } from "crypto";
|
||||
import { writeFileSync, renameSync, existsSync, readFileSync, appendFileSync, statSync, openSync, closeSync, unlinkSync } from "fs";
|
||||
import { appendJsonl, readJsonl, hasInjection } from "./jsonl-store";
|
||||
import { scan } from "./redact-engine";
|
||||
|
||||
export type DecisionKind = "decide" | "supersede" | "redact";
|
||||
export type DecisionScope = "repo" | "branch" | "issue";
|
||||
export type DecisionSource = "user" | "skill" | "agent";
|
||||
|
||||
export const DECISION_SCOPES: readonly DecisionScope[] = ["repo", "branch", "issue"];
|
||||
export const DECISION_SOURCES: readonly DecisionSource[] = ["user", "skill", "agent"];
|
||||
|
||||
export interface DecisionEvent {
|
||||
id: string;
|
||||
kind: DecisionKind;
|
||||
decision?: string;
|
||||
rationale?: string;
|
||||
alternatives_considered?: string;
|
||||
/** For supersede/redact: the id of the `decide` event being acted on. */
|
||||
supersedes?: string;
|
||||
scope: DecisionScope;
|
||||
branch?: string;
|
||||
issue?: string;
|
||||
date: string;
|
||||
session?: string;
|
||||
source: DecisionSource;
|
||||
confidence?: number;
|
||||
}
|
||||
|
||||
export interface ActiveDecision extends DecisionEvent {
|
||||
kind: "decide";
|
||||
}
|
||||
|
||||
export interface DecisionPaths {
|
||||
log: string;
|
||||
snapshot: string;
|
||||
archive: string;
|
||||
}
|
||||
|
||||
/** Resolve the per-project decision store paths. Bins pass slug + GSTACK_HOME. */
|
||||
export function decisionPaths(slug: string, gstackHome?: string): DecisionPaths {
|
||||
const home = gstackHome || process.env.GSTACK_HOME || join(homedir(), ".gstack");
|
||||
const dir = join(home, "projects", slug || "unknown");
|
||||
return {
|
||||
log: join(dir, "decisions.jsonl"),
|
||||
snapshot: join(dir, "decisions.active.json"),
|
||||
archive: join(dir, "decisions.archive.jsonl"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Datamark resurfaced decision text so a stored string can't masquerade as
|
||||
* instructions or break out of the Context Recovery fence when it lands in agent
|
||||
* context (codex hardening #3: resurface = DATA, not instructions). Write-time
|
||||
* `hasInjection` is a denylist; this is the render-boundary defense-in-depth that
|
||||
* also covers `--all`/snapshot reads and records written before a pattern existed.
|
||||
* Neutralizes: control chars, newlines (defensive — events are single-line),
|
||||
* code fences, `---` banner sentinels, and `<|role|>` / `</system>` markers.
|
||||
*/
|
||||
export function datamark(text: string): string {
|
||||
const ZWSP = "\u200b"; // zero-width space: breaks token recognition, near-invisible
|
||||
return text
|
||||
// strip C0/C1 control chars + Unicode line terminators (U+0085/2028/2029 render as
|
||||
// newlines in many tokenizers/markdown; "strip newlines" must cover them)
|
||||
.replace(/[\u0000-\u001f\u007f\u0085\u2028\u2029]/g, " ")
|
||||
.replace(/`{3,}/g, "'''") // neutralize markdown code fences
|
||||
.replace(/-{3,}/g, "\u2014") // neutralize `---` banner sentinels (em dash)
|
||||
.replace(/<\|/g, `<${ZWSP}|`) // neutralize <|im_start|>-style chat markers
|
||||
.replace(/\|>/g, `|${ZWSP}>`)
|
||||
.replace(/<(\/?)(system|user|assistant|tool)>/gi, `<${ZWSP}$1$2>`) // neutralize role tags
|
||||
// neutralize chat turn-prefixes (Human:/Assistant:/System:/User:) — defeat the
|
||||
// angle-tag pass and are Claude's native turn delimiters
|
||||
.replace(/\b(human|assistant|system|user)(\s*):/gi, `$1${ZWSP}$2:`);
|
||||
}
|
||||
|
||||
export type ValidateResult =
|
||||
| { ok: true; event: DecisionEvent }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Validate + stamp a `decide` event. Rejects (no silent persist) on:
|
||||
* - missing/empty decision text or invalid scope/source,
|
||||
* - injection-like content in any free-text field (datamark-on-write),
|
||||
* - a HIGH-tier secret (redact engine) in any free-text field.
|
||||
*/
|
||||
export function validateDecide(input: Partial<DecisionEvent>): ValidateResult {
|
||||
if (!input.decision || typeof input.decision !== "string" || !input.decision.trim()) {
|
||||
return { ok: false, error: "decision text is required" };
|
||||
}
|
||||
const scope = input.scope ?? "repo";
|
||||
if (!DECISION_SCOPES.includes(scope)) {
|
||||
return { ok: false, error: `invalid scope "${scope}"; must be ${DECISION_SCOPES.join("|")}` };
|
||||
}
|
||||
const source = input.source ?? "agent";
|
||||
if (!DECISION_SOURCES.includes(source)) {
|
||||
return { ok: false, error: `invalid source "${source}"; must be ${DECISION_SOURCES.join("|")}` };
|
||||
}
|
||||
if (input.confidence !== undefined) {
|
||||
const c = Number(input.confidence);
|
||||
if (!Number.isInteger(c) || c < 1 || c > 10) {
|
||||
return { ok: false, error: "confidence must be integer 1-10" };
|
||||
}
|
||||
}
|
||||
|
||||
// Scan ALL stored free-text — incl. branch/issue, which are surfaced (and emitted raw
|
||||
// via --json), so they must not carry secrets or injection either (Codex finding).
|
||||
const freeText = [input.decision, input.rationale, input.alternatives_considered, input.branch, input.issue]
|
||||
.filter((s): s is string => typeof s === "string")
|
||||
.join("\n");
|
||||
|
||||
if (hasInjection(freeText)) {
|
||||
return { ok: false, error: "decision contains instruction-like content (injection), rejected" };
|
||||
}
|
||||
const redacted = scan(freeText);
|
||||
if (redacted.counts.HIGH > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `decision contains a HIGH-tier secret (${redacted.counts.HIGH} finding(s)); rotate + remove it, do not log secrets`,
|
||||
};
|
||||
}
|
||||
// MEDIUM = PII / credential-shaped content. The taxonomy says "confirm via
|
||||
// AskUserQuestion", but this store is NON-INTERACTIVE and syncs cross-machine,
|
||||
// so there is no confirm path — fail closed rather than silently persist + sync a
|
||||
// secret that later resurfaces into agent context.
|
||||
if (redacted.counts.MEDIUM > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `decision contains MEDIUM-tier sensitive content (${redacted.counts.MEDIUM} finding(s): PII or credential-shaped). This store is non-interactive and syncs across machines, so it fails closed — remove or rephrase the value before logging.`,
|
||||
};
|
||||
}
|
||||
|
||||
const event: DecisionEvent = {
|
||||
id: input.id || randomUUID(),
|
||||
kind: "decide",
|
||||
decision: input.decision.trim(),
|
||||
rationale: input.rationale,
|
||||
alternatives_considered: input.alternatives_considered,
|
||||
scope,
|
||||
branch: input.branch || undefined,
|
||||
issue: input.issue || undefined,
|
||||
date: input.date || new Date().toISOString(),
|
||||
session: input.session,
|
||||
source,
|
||||
confidence: input.confidence === undefined ? undefined : Number(input.confidence),
|
||||
};
|
||||
return { ok: true, event };
|
||||
}
|
||||
|
||||
/** Build a supersede/redact event referencing an existing decide-event id. */
|
||||
export function makeRefEvent(kind: "supersede" | "redact", targetId: string, opts: { session?: string; source?: DecisionSource } = {}): DecisionEvent {
|
||||
return {
|
||||
id: randomUUID(),
|
||||
kind,
|
||||
supersedes: targetId,
|
||||
scope: "repo",
|
||||
date: new Date().toISOString(),
|
||||
session: opts.session,
|
||||
source: opts.source ?? "agent",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the ACTIVE decisions: `decide` events whose id is NOT referenced by any
|
||||
* later `supersede`/`redact`. Dangling refs (supersede/redact pointing at an id
|
||||
* that has no `decide`) are tolerated — ignored, never thrown. Returned in date
|
||||
* order (oldest first).
|
||||
*/
|
||||
export function computeActive(events: DecisionEvent[]): ActiveDecision[] {
|
||||
const retired = new Set<string>();
|
||||
for (const e of events) {
|
||||
if ((e.kind === "supersede" || e.kind === "redact") && e.supersedes) {
|
||||
retired.add(e.supersedes); // dangling target id is harmless — just a no-op
|
||||
}
|
||||
}
|
||||
return events
|
||||
.filter((e): e is ActiveDecision => e.kind === "decide" && !retired.has(e.id))
|
||||
.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope filter for resurfacing: repo-scoped decisions always apply; branch-scoped
|
||||
* only when the branch matches the current context; issue-scoped only when the
|
||||
* issue matches. (Recency != relevance — callers filter by scope, not just date.)
|
||||
*/
|
||||
export function filterByScope(active: ActiveDecision[], ctx: { branch?: string; issue?: string }): ActiveDecision[] {
|
||||
return active.filter((d) => {
|
||||
if (d.scope === "repo") return true;
|
||||
if (d.scope === "branch") return !!ctx.branch && d.branch === ctx.branch;
|
||||
if (d.scope === "issue") return !!ctx.issue && d.issue === ctx.issue;
|
||||
return false; // unknown/garbage scope: fail conservative, don't leak into every context
|
||||
});
|
||||
}
|
||||
|
||||
/** Append a validated event atomically (single-line, concurrency-safe). */
|
||||
export function appendEvent(paths: DecisionPaths, event: DecisionEvent): void {
|
||||
appendJsonl(paths.log, event);
|
||||
}
|
||||
|
||||
/** Read all events tolerantly (skips malformed/partial-tail lines). */
|
||||
export function readEvents(paths: DecisionPaths): DecisionEvent[] {
|
||||
return readJsonl<DecisionEvent>(paths.log);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the bounded active snapshot (`decisions.active.json`) atomically. Context
|
||||
* Recovery and search read THIS, not the full history — session start stays
|
||||
* O(active), not O(history).
|
||||
*/
|
||||
export function writeSnapshot(paths: DecisionPaths, active: ActiveDecision[]): void {
|
||||
const tmp = `${paths.snapshot}.tmp.${process.pid}`;
|
||||
writeFileSync(tmp, JSON.stringify(active), "utf-8");
|
||||
renameSync(tmp, paths.snapshot);
|
||||
}
|
||||
|
||||
/** Read the bounded active snapshot. Returns [] if missing/corrupt (caller may rebuild). */
|
||||
export function readSnapshot(paths: DecisionPaths): ActiveDecision[] {
|
||||
if (!existsSync(paths.snapshot)) return [];
|
||||
try {
|
||||
const v = JSON.parse(readFileSync(paths.snapshot, "utf-8"));
|
||||
return Array.isArray(v) ? (v as ActiveDecision[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Recompute active from the event log and refresh the snapshot. Returns active. */
|
||||
export function rebuildSnapshot(paths: DecisionPaths): ActiveDecision[] {
|
||||
const active = computeActive(readEvents(paths));
|
||||
writeSnapshot(paths, active);
|
||||
return active;
|
||||
}
|
||||
|
||||
export interface CompactResult {
|
||||
activeCount: number;
|
||||
/** superseded decisions moved to the archive (history kept). */
|
||||
archivedCount: number;
|
||||
/** redacted decisions DROPPED entirely (expunged, NOT archived). */
|
||||
expungedCount: number;
|
||||
/** true when compaction was skipped to avoid clobbering a concurrent writer/compactor. */
|
||||
skipped?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact the event log to the active set.
|
||||
* - active decisions → kept in `decisions.jsonl`,
|
||||
* - superseded decisions → appended to `decisions.archive.jsonl` (history),
|
||||
* - REDACTED decisions → expunged (dropped, NOT archived) — that's redact's job:
|
||||
* a `redact` is how an accidentally-captured secret leaves the store for good.
|
||||
*
|
||||
* Concurrency: appends are lock-free (O_APPEND), but compact is a read-modify-rewrite
|
||||
* that would clobber an append landing in its window. Two guards: (1) an O_EXCL lock
|
||||
* file serializes compactions (no double-archive / tmp tear); (2) the log size is
|
||||
* re-checked immediately before the destructive write — if an append landed since the
|
||||
* read, compact ABORTS untouched (returns skipped) so no decision is ever lost. The
|
||||
* caller re-runs. Atomic rewrite (tmp + rename); refreshes the snapshot.
|
||||
*/
|
||||
export function compact(paths: DecisionPaths): CompactResult {
|
||||
const lockPath = `${paths.log}.compact.lock`;
|
||||
let lockFd: number;
|
||||
try {
|
||||
lockFd = openSync(lockPath, "wx"); // O_EXCL|O_CREAT — throws EEXIST if a compact holds it
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "EEXIST") {
|
||||
return { activeCount: computeActive(readEvents(paths)).length, archivedCount: 0, expungedCount: 0, skipped: true };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
try {
|
||||
const sizeBefore = existsSync(paths.log) ? statSync(paths.log).size : 0;
|
||||
const events = readEvents(paths);
|
||||
const active = computeActive(events);
|
||||
const activeIds = new Set(active.map((d) => d.id));
|
||||
const redactedIds = new Set(
|
||||
events.filter((e) => e.kind === "redact" && e.supersedes).map((e) => e.supersedes as string),
|
||||
);
|
||||
// Superseded = a decide that's neither active nor redacted. Archive these for history.
|
||||
const superseded = events.filter(
|
||||
(e): e is DecisionEvent => e.kind === "decide" && !activeIds.has(e.id) && !redactedIds.has(e.id),
|
||||
);
|
||||
|
||||
// Append-race guard: if the log grew/changed since we read it, an append landed —
|
||||
// rewriting now would drop it. Abort untouched; the caller re-runs.
|
||||
const sizeNow = existsSync(paths.log) ? statSync(paths.log).size : 0;
|
||||
if (sizeNow !== sizeBefore) {
|
||||
return { activeCount: active.length, archivedCount: 0, expungedCount: 0, skipped: true };
|
||||
}
|
||||
|
||||
// One batched append (not one open/write/close per event) — matches the atomic
|
||||
// batched rewrite of the active log below and shrinks the mid-compact crash window.
|
||||
if (superseded.length) {
|
||||
appendFileSync(paths.archive, superseded.map((e) => JSON.stringify(e)).join("\n") + "\n", "utf-8");
|
||||
}
|
||||
|
||||
const tmp = `${paths.log}.tmp.${process.pid}`;
|
||||
writeFileSync(tmp, active.map((d) => JSON.stringify(d)).join("\n") + (active.length ? "\n" : ""), "utf-8");
|
||||
renameSync(tmp, paths.log);
|
||||
writeSnapshot(paths, active);
|
||||
|
||||
return { activeCount: active.length, archivedCount: superseded.length, expungedCount: redactedIds.size };
|
||||
} finally {
|
||||
closeSync(lockFd);
|
||||
try {
|
||||
unlinkSync(lockPath);
|
||||
} catch {
|
||||
// best-effort lock cleanup; a leftover lock only blocks the NEXT compact, which re-runs
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* jsonl-store — shared, audited plumbing for gstack's append-only JSONL stores.
|
||||
*
|
||||
* Single source of truth for the three things every JSONL store must get right:
|
||||
* 1. Injection sanitization (the prompt-injection patterns that must NOT survive
|
||||
* into agent context when a record is later resurfaced).
|
||||
* 2. Atomic single-line append (concurrent agents must not corrupt the file).
|
||||
* 3. Tolerant read (a partially-written tail or one corrupt line must not take
|
||||
* down the whole read).
|
||||
*
|
||||
* Extracted from `bin/gstack-learnings-log` (D2A) so `gstack-learnings-*` and the
|
||||
* new `gstack-decision-*` bins share ONE audited path — a new injection pattern or
|
||||
* a write-atomicity fix lands in both at once, never drifts. Per the
|
||||
* `squash-with-regen` / DRY discipline + the eng-review D2A decision.
|
||||
*/
|
||||
|
||||
import { appendFileSync, readFileSync, existsSync } from "fs";
|
||||
|
||||
/**
|
||||
* Prompt-injection patterns. If any matches a free-text field (insight, rationale,
|
||||
* decision), the record is REJECTED at write time — these strings could otherwise
|
||||
* be replayed into a future agent's context as instructions when the record is
|
||||
* resurfaced. Keep this list the ONLY copy (callers import it; do not re-declare).
|
||||
*/
|
||||
export const INJECTION_PATTERNS: readonly RegExp[] = [
|
||||
/ignore\s+(all\s+)?previous\s+(instructions|context|rules)/i,
|
||||
/you\s+are\s+now\s+/i,
|
||||
/always\s+output\s+no\s+findings/i,
|
||||
/skip\s+(all\s+)?(security|review|checks)/i,
|
||||
/override[:\s]/i,
|
||||
/\bsystem\s*:/i,
|
||||
/\bassistant\s*:/i,
|
||||
/\buser\s*:/i,
|
||||
/\bhuman\s*:/i, // Claude's native turn prefix — bypassed the denylist AND datamark
|
||||
/disregard\s+(all\s+)?(previous|above|prior)/i,
|
||||
/from\s+now\s+on\b/i,
|
||||
/do\s+not\s+(report|flag|mention)/i,
|
||||
/approve\s+(all|every|this)/i,
|
||||
];
|
||||
|
||||
/** True if `text` contains an instruction-like injection pattern. */
|
||||
export function hasInjection(text: string): boolean {
|
||||
return INJECTION_PATTERNS.some((p) => p.test(text));
|
||||
}
|
||||
|
||||
/** Returns the first injection pattern that matches, or null. For actionable errors. */
|
||||
export function firstInjectionMatch(text: string): RegExp | null {
|
||||
return INJECTION_PATTERNS.find((p) => p.test(text)) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic single-line append of `obj` as one JSON line.
|
||||
*
|
||||
* Concurrency: opens with `a` (O_APPEND); a single write under PIPE_BUF (>=512,
|
||||
* 4096+ on macOS/Linux) is atomic across processes, so concurrent agents appending
|
||||
* never interleave. Records MUST serialize to a single line (no embedded newline) —
|
||||
* we throw rather than risk a multi-line record breaking the one-record-per-line
|
||||
* invariant the tolerant reader relies on.
|
||||
*
|
||||
* Caveat: a record larger than PIPE_BUF loses the cross-process atomicity guarantee.
|
||||
* Keep records line-bounded; very large free-text should be truncated by the caller.
|
||||
*/
|
||||
export function appendJsonl(path: string, obj: unknown): void {
|
||||
const line = JSON.stringify(obj);
|
||||
if (line.includes("\n")) {
|
||||
throw new Error("jsonl-store: record serialized to multiple lines (embedded newline)");
|
||||
}
|
||||
appendFileSync(path, line + "\n", { encoding: "utf-8" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Tolerant reader: parse each line, SKIP malformed ones (partial-write tail, a
|
||||
* corrupt line, a non-JSON line) rather than throwing. A broken line never takes
|
||||
* down the whole read. Missing file → empty array. Unknown fields are preserved
|
||||
* (forward-compatible: a schema bump on the writer doesn't break older readers).
|
||||
*/
|
||||
export function readJsonl<T = unknown>(path: string): T[] {
|
||||
if (!existsSync(path)) return [];
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(path, "utf-8");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const out: T[] = [];
|
||||
for (const line of raw.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
out.push(JSON.parse(trimmed) as T);
|
||||
} catch {
|
||||
// Malformed line (partial tail / corruption) — skip, keep reading.
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user