Merge remote-tracking branch 'origin/main' into garrytan/triage-open-issues

# Conflicts:
#	CHANGELOG.md
#	VERSION
#	package.json
This commit is contained in:
Garry Tan
2026-06-08 06:22:54 -07:00
77 changed files with 3073 additions and 65 deletions
+28
View File
@@ -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
View File
@@ -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
View File
@@ -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";
}
+93
View File
@@ -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);
}
+325
View File
@@ -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
}
}
}
+96
View File
@@ -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;
}