mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-13 03:48:06 +01:00
Merge remote-tracking branch 'origin/garrytan/opus55-wip-B' into garrytan/opus-5-5-prompt-audit
This commit is contained in:
commit
522e300d4e
21 files changed
+1352
-259
No files matched your search
@@ -294,6 +294,39 @@ describe('init + sync + restore round-trip', () => {
|
||||
expect(log.stdout).toMatch(/sync: 1 file/);
|
||||
});
|
||||
|
||||
test('staged transcript pages stay queued without transcript consent; other classes still push', () => {
|
||||
run(['gstack-artifacts-init', '--remote', bareRemote]);
|
||||
run(['gstack-config', 'set', 'artifacts_sync_mode', 'full']);
|
||||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'),
|
||||
'{"skill":"x","insight":"y","ts":"2026-04-22T10:00:00Z"}\n');
|
||||
const page = 'transcripts/run-1-1/session.md';
|
||||
fs.mkdirSync(path.join(tmpHome, 'transcripts', 'run-1-1'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpHome, page), '# Session\n\nhello\n');
|
||||
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||||
run(['gstack-brain-enqueue', page]);
|
||||
const remoteFiles = () =>
|
||||
spawnSync('git', ['--git-dir=' + bareRemote, 'ls-tree', '-r', '--name-only', 'main'], { encoding: 'utf-8', timeout: 30_000 }).stdout;
|
||||
|
||||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||||
expect(remoteFiles()).toContain('projects/p/learnings.jsonl');
|
||||
expect(remoteFiles()).not.toContain(page);
|
||||
expect(spoolText()).toContain(page);
|
||||
expect(fs.existsSync(path.join(tmpHome, page))).toBe(true);
|
||||
|
||||
// A stored off or legacy value is not consent either.
|
||||
for (const value of ['off', 'A']) {
|
||||
fs.appendFileSync(path.join(tmpHome, 'config.yaml'), `transcript_ingest_mode: ${value}\n`);
|
||||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||||
expect(remoteFiles()).not.toContain(page);
|
||||
}
|
||||
|
||||
expect(run(['gstack-config', 'set', 'transcript_ingest_mode', 'recent']).status).toBe(0);
|
||||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||||
expect(remoteFiles()).toContain(page);
|
||||
expect(spoolText()).not.toContain(page);
|
||||
});
|
||||
|
||||
test('restore round-trip: writes on machine A visible on machine B', () => {
|
||||
// Machine A.
|
||||
run(['gstack-artifacts-init', '--remote', bareRemote]);
|
||||
|
||||
@@ -26,6 +26,11 @@
|
||||
* cross_project_learnings ("unset triggers the first-time prompt") and
|
||||
* redact_repo_visibility ("empty falls through to gh/glab detection")
|
||||
* depend on receiving "" successfully.
|
||||
*
|
||||
* The reverse direction is pinned too: every key in the DEFAULTS table must
|
||||
* be read by code (bin/, lib/, scripts/, setup, or a .tmpl), or be listed in
|
||||
* PROSE_ONLY_KEYS with a reason. A documented setting no code reads is a
|
||||
* switch that does nothing, which is how a consent choice once went unread.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
@@ -109,6 +114,74 @@ function keysReadInTree(root = ROOT): string[] {
|
||||
return [...keys].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* DEFAULTS-table keys no code reads. Each entry needs a reason; an empty list
|
||||
* means every documented setting has a reader.
|
||||
*/
|
||||
const PROSE_ONLY_KEYS: Record<string, string> = {};
|
||||
|
||||
const READER_DIRS = ['bin', 'lib', 'scripts'];
|
||||
const READER_FILES = ['setup'];
|
||||
|
||||
/**
|
||||
* Source text a config reader could live in: everything under READER_DIRS,
|
||||
* READER_FILES, and every .tmpl in the tree. bin/gstack-config counts too,
|
||||
* minus its documentation header and its DEFAULTS table.
|
||||
*/
|
||||
function readerCorpus(root = ROOT): string {
|
||||
const files = new Set<string>();
|
||||
const walk = (dir: string, all: boolean) => {
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const ent of entries) {
|
||||
if (SKIP_DIRS.has(ent.name) || ent.isSymbolicLink()) continue;
|
||||
const full = path.join(dir, ent.name);
|
||||
if (ent.isDirectory()) walk(full, all);
|
||||
else if (all || ent.name.endsWith('.tmpl')) files.add(full);
|
||||
}
|
||||
};
|
||||
for (const d of READER_DIRS) walk(path.join(root, d), true);
|
||||
walk(root, false);
|
||||
for (const f of READER_FILES) files.add(path.join(root, f));
|
||||
return [...files]
|
||||
.map((f) => {
|
||||
let text = '';
|
||||
try {
|
||||
text = fs.readFileSync(f, 'utf-8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
if (f === path.join(root, 'bin', 'gstack-config')) {
|
||||
text = text.replace(/CONFIG_HEADER='[\s\S]*?\n'\n/, '').replace(/lookup_default\(\) \{[\s\S]*?\n\}/, '');
|
||||
}
|
||||
return text;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* A key counts as read when the corpus calls `gstack-config get|has <key>`
|
||||
* (directly or through a variable naming the config binary), passes
|
||||
* ['get', '<key>'] to a spawn, calls a config-reader function with the key,
|
||||
* or anchors the YAML key (`^<key>:`). Wildcard keys match by prefix.
|
||||
*/
|
||||
function isReadInCorpus(arm: string, corpus: string): boolean {
|
||||
const esc = (v: string) => v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const wildcard = arm.endsWith('*');
|
||||
const k = wildcard ? esc(arm.slice(0, -1)) : `${esc(arm)}(?![A-Za-z0-9_])`;
|
||||
const re = new RegExp([
|
||||
`(?:gstack-config|\\$\\{?[A-Z_]*CONF[A-Z_]*\\}?)["']?[ \\t]+(?:get|has)[ \\t]+["']?${k}`,
|
||||
`['"](?:get|has)['"]\\s*,\\s*['"]${k}`,
|
||||
`(?:readConfigKey|readConfigKeyWithRoot|configValue|configGet|gstack_read_config_key|gstack_config_select)\\(?[ \\t]*['"\`]?${k}`,
|
||||
`\\^${k}${wildcard ? '' : ':'}`,
|
||||
].join('|'));
|
||||
return re.test(corpus);
|
||||
}
|
||||
|
||||
describe('gstack-config defaults (gate, free)', () => {
|
||||
test('retired checkpoint keys have no defaults or advertised configuration', () => {
|
||||
expect(fs.readFileSync(CONFIG_BIN, 'utf8')).not.toMatch(/checkpoint/i);
|
||||
@@ -146,6 +219,49 @@ describe('gstack-config defaults (gate, free)', () => {
|
||||
expect(uncovered).toEqual([]);
|
||||
});
|
||||
|
||||
test('every DEFAULTS key is read by code or listed as prose-only', () => {
|
||||
const corpus = readerCorpus();
|
||||
const arms = defaultArms();
|
||||
const unread = arms.filter((a) => !(a in PROSE_ONLY_KEYS) && !isReadInCorpus(a, corpus));
|
||||
if (unread.length > 0) {
|
||||
throw new Error(
|
||||
`DEFAULTS keys with no reader: ${unread.join(', ')}. Fix one of two ways: ` +
|
||||
`add the code that reads the key (gstack-config get/has <key> in bin/, lib/, scripts/, setup or a .tmpl), ` +
|
||||
`or add it to PROSE_ONLY_KEYS in ${SELF} with the reason it has no reader.`,
|
||||
);
|
||||
}
|
||||
const stale = Object.keys(PROSE_ONLY_KEYS).filter((k) => !arms.includes(k) || isReadInCorpus(k, corpus));
|
||||
if (stale.length > 0) {
|
||||
throw new Error(
|
||||
`PROSE_ONLY_KEYS entries that are no longer prose-only: ${stale.join(', ')}. Fix one of two ways: ` +
|
||||
`remove the entry because code now reads the key, or remove it because the key left the DEFAULTS table.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the reader matcher finds each reader form and nothing in prose', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-config-readers-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(root, 'bin'));
|
||||
fs.mkdirSync(path.join(root, 'docs'));
|
||||
fs.writeFileSync(path.join(root, 'bin', 'a'), [
|
||||
'V=$("$CONFIG_BIN" get key_one 2>/dev/null)',
|
||||
'gstack-config has key_two',
|
||||
"spawnSync(bin, ['get', 'key_three'], { timeout: 2000 })",
|
||||
"readConfigKey('key_four')",
|
||||
'grep -E "^key_five_${hash}:" "$F"',
|
||||
].join('\n'));
|
||||
fs.writeFileSync(path.join(root, 'docs', 'prose.md'), 'gstack-config get key_six\n');
|
||||
fs.writeFileSync(path.join(root, 'skill.md.tmpl'), 'gstack-config get key_seven\n');
|
||||
const corpus = readerCorpus(root);
|
||||
const read = ['key_one', 'key_two', 'key_three', 'key_four', 'key_five_*', 'key_six', 'key_seven', 'key_one_more']
|
||||
.filter((k) => isReadInCorpus(k, corpus));
|
||||
expect(read).toEqual(['key_one', 'key_two', 'key_three', 'key_four', 'key_five_*', 'key_seven']);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('an unknown key exits non-zero, so the caller fallback fires', () => {
|
||||
const r = get('definitely_not_a_gstack_key_9f3a');
|
||||
expect(r.code).not.toBe(0);
|
||||
@@ -223,3 +339,60 @@ describe('design_detector_install_prompted (true|false, rejecting validator)', (
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('transcript_ingest_mode (recent|all|off, rejecting validator)', () => {
|
||||
const env = { ...process.env, GSTACK_STATE_ROOT: STATE };
|
||||
const set = (key: string, value: string) =>
|
||||
spawnSync('bash', [CONFIG_BIN, 'set', key, value], { encoding: 'utf-8', timeout: 30_000, env });
|
||||
const has = (key: string) => spawnSync('bash', [CONFIG_BIN, 'has', key], { encoding: 'utf-8', timeout: 30_000, env }).status;
|
||||
const file = path.join(STATE, 'config.yaml');
|
||||
const snapshot = () => (fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : null);
|
||||
|
||||
test('accepts recent, all and off', () => {
|
||||
for (const value of ['recent', 'all', 'off']) {
|
||||
expect(set('transcript_ingest_mode', value).status).toBe(0);
|
||||
expect(get('transcript_ingest_mode').out).toBe(value);
|
||||
}
|
||||
expect(has('transcript_ingest_mode')).toBe(0);
|
||||
});
|
||||
|
||||
test('rejects legacy letters and unknown values with the file unchanged', () => {
|
||||
set('transcript_ingest_mode', 'recent');
|
||||
for (const value of ['A', 'incremental', 'new-only', 'yes', 'Recent']) {
|
||||
const before = snapshot();
|
||||
const r = set('transcript_ingest_mode', value);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toContain(
|
||||
`Error: transcript_ingest_mode '${value}' not recognized. Valid values: recent, all, off. Existing value left unchanged.`,
|
||||
);
|
||||
expect(snapshot()).toBe(before);
|
||||
}
|
||||
expect(get('transcript_ingest_mode').out).toBe('recent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('set rejects empty and multi-line values for every key', () => {
|
||||
const env = { ...process.env, GSTACK_STATE_ROOT: STATE };
|
||||
const file = path.join(STATE, 'config.yaml');
|
||||
const snapshot = () => (fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : null);
|
||||
|
||||
test('an empty value exits 1 and leaves the file unchanged', () => {
|
||||
for (const key of ['workspace_root', 'transcript_ingest_mode', 'some_free_form_key']) {
|
||||
const before = snapshot();
|
||||
const r = spawnSync('bash', [CONFIG_BIN, 'set', key, ''], { encoding: 'utf-8', timeout: 30_000, env });
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toMatch(/empty/i);
|
||||
expect(snapshot()).toBe(before);
|
||||
}
|
||||
});
|
||||
|
||||
test('a value containing a newline exits 1 and leaves the file unchanged', () => {
|
||||
for (const key of ['workspace_root', 'transcript_ingest_mode', 'some_free_form_key']) {
|
||||
const before = snapshot();
|
||||
const r = spawnSync('bash', [CONFIG_BIN, 'set', key, 'recent\nall'], { encoding: 'utf-8', timeout: 30_000, env });
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toMatch(/newline/i);
|
||||
expect(snapshot()).toBe(before);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,458 @@
|
||||
/**
|
||||
* Transcript consent (transcript_ingest_mode) in the memory stage.
|
||||
*
|
||||
* Transcripts ingest only for a stored `recent` (last 90 days) or `all` (all
|
||||
* history). An absent key, `off`, legacy gate values and anything else skip
|
||||
* transcripts while the other memory types still sync, and the sync prints a
|
||||
* notice naming how to choose. Runs the real orchestrator and memory ingest
|
||||
* against a stub gbrain in a temp HOME / GSTACK_HOME.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { spawn, spawnSync } from "child_process";
|
||||
|
||||
import {
|
||||
MEMORY_INGEST_TYPES,
|
||||
applyTranscriptConsent,
|
||||
transcriptConsentNotice,
|
||||
} from "../bin/gstack-gbrain-sync";
|
||||
import { normalizeTranscriptConsent } from "../bin/gstack-memory-ingest";
|
||||
|
||||
const ROOT = join(import.meta.dir, "..");
|
||||
const SYNC = join(ROOT, "bin", "gstack-gbrain-sync.ts");
|
||||
const INGEST = join(ROOT, "bin", "gstack-memory-ingest.ts");
|
||||
const HINT =
|
||||
"To choose: run /sync-gbrain, or gstack-config set transcript_ingest_mode recent|all|off. Details: setup-gbrain/memory.md#transcripts";
|
||||
const notice = (value: string) =>
|
||||
`gbrain-sync: transcripts skipped (transcript_ingest_mode=${value}). Other memory still syncs. ${HINT}`;
|
||||
|
||||
describe("normalizeTranscriptConsent", () => {
|
||||
const consent = (yaml: string | null) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "gstack-consent-"));
|
||||
try {
|
||||
if (yaml !== null) writeFileSync(join(dir, "config.yaml"), yaml);
|
||||
return normalizeTranscriptConsent({ GSTACK_STATE_ROOT: dir, HOME: dir });
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
|
||||
test("only recent and all are consent", () => {
|
||||
expect(consent("transcript_ingest_mode: recent\n")).toEqual({ affirmative: true, window: "recent", reason: "recent", value: "recent" });
|
||||
expect(consent("transcript_ingest_mode: all\n")).toEqual({ affirmative: true, window: "all", reason: "all", value: "all" });
|
||||
expect(consent("transcript_ingest_mode: off\n")).toMatchObject({ affirmative: false, window: null, reason: "off" });
|
||||
expect(consent(null)).toEqual({ affirmative: false, window: null, reason: "not-set", value: null });
|
||||
expect(consent("telemetry: off\n")).toMatchObject({ affirmative: false, reason: "not-set" });
|
||||
});
|
||||
|
||||
test("legacy gate values, any case and padding, need a new choice", () => {
|
||||
for (const v of [" A ", "b", "C", "D", "e", "incremental", "Incremental"]) {
|
||||
expect(consent(`transcript_ingest_mode: ${v}\n`)).toMatchObject({ affirmative: false, window: null, reason: "legacy" });
|
||||
}
|
||||
});
|
||||
|
||||
test("anything else is unrecognized, not consent", () => {
|
||||
for (const v of ["yes", "new-only", "90d", "true"]) {
|
||||
expect(consent(`transcript_ingest_mode: ${v}\n`)).toMatchObject({ affirmative: false, reason: "unrecognized", value: v });
|
||||
}
|
||||
});
|
||||
|
||||
test("the last line wins, like gstack-config get", () => {
|
||||
expect(consent("transcript_ingest_mode: all\ntranscript_ingest_mode: off\n")).toMatchObject({ reason: "off" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyTranscriptConsent", () => {
|
||||
const yes = { affirmative: true, window: "recent" as const, reason: "recent" as const, value: "recent" };
|
||||
const no = { affirmative: false, window: null, reason: "not-set" as const, value: null };
|
||||
|
||||
test("without consent, transcript leaves the default and the rest is pinned as an explicit list", () => {
|
||||
const r = applyTranscriptConsent({ sources: null, why: "default: no federated gstack source registered" }, no);
|
||||
expect(r.sources).toEqual(MEMORY_INGEST_TYPES.filter((t) => t !== "transcript"));
|
||||
expect(r.selected).toEqual(r.sources!);
|
||||
expect(r.override).toBe(false);
|
||||
});
|
||||
|
||||
test("with consent, the default stays null so the ingest walks its own default", () => {
|
||||
const r = applyTranscriptConsent({ sources: null, why: "default: no federated gstack source registered" }, yes);
|
||||
expect(r.sources).toBeNull();
|
||||
expect(r.selected).toEqual([...MEMORY_INGEST_TYPES]);
|
||||
});
|
||||
|
||||
test("an explicit list naming transcript overrides; a derived list does not", () => {
|
||||
expect(applyTranscriptConsent({ sources: ["transcript", "learning"], why: "--sources" }, no)).toMatchObject({ override: true, sources: ["transcript", "learning"] });
|
||||
expect(applyTranscriptConsent({ sources: ["transcript"], why: "GSTACK_MEMORY_INGEST_SOURCES" }, no)).toMatchObject({ override: true });
|
||||
const federated = applyTranscriptConsent({ sources: ["transcript", "learning"], why: "default: ceo-plan,design-doc already indexed" }, no);
|
||||
expect(federated).toMatchObject({ override: false, sources: ["learning"] });
|
||||
});
|
||||
|
||||
test("notice copy", () => {
|
||||
expect(transcriptConsentNotice(no, true)).toBe(notice("not set"));
|
||||
expect(transcriptConsentNotice({ ...no, reason: "legacy", value: "A" }, false)).toBe(notice("A"));
|
||||
expect(transcriptConsentNotice({ ...no, reason: "off", value: "off" }, false)).toBe("gbrain-sync: transcripts off (your choice)");
|
||||
expect(transcriptConsentNotice({ ...no, reason: "off", value: "off" }, true)).toBeNull();
|
||||
expect(transcriptConsentNotice(yes, false)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
interface Sandbox {
|
||||
home: string;
|
||||
gstackHome: string;
|
||||
stagedList: string;
|
||||
calls: string;
|
||||
env: Record<string, string>;
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function sandbox(opts: { federated?: boolean; mode?: string } = {}): Sandbox {
|
||||
const home = mkdtempSync(join(tmpdir(), "gstack-tmode-"));
|
||||
const gstackHome = join(home, ".gstack");
|
||||
const worktree = join(home, ".gstack-brain-worktree");
|
||||
const bindir = join(home, "bin");
|
||||
const stagedList = join(home, "staged.list");
|
||||
const calls = join(home, "gbrain.calls");
|
||||
for (const d of [gstackHome, worktree, bindir, join(home, ".gbrain")]) mkdirSync(d, { recursive: true });
|
||||
writeFileSync(join(home, ".gbrain", "config.json"), JSON.stringify({ engine: "pglite", database_url: "pglite:///test" }));
|
||||
if (opts.mode !== undefined) writeFileSync(join(gstackHome, "config.yaml"), `transcript_ingest_mode: ${opts.mode}\n`);
|
||||
|
||||
// Curated memory: one learning and one CEO plan.
|
||||
const proj = join(gstackHome, "projects", "acme-app");
|
||||
mkdirSync(join(proj, "ceo-plans"), { recursive: true });
|
||||
writeFileSync(join(proj, "ceo-plans", "2026-09-01-brand.md"), "# Brand plan\n\nShip the brand refresh.\n");
|
||||
writeFileSync(
|
||||
join(proj, "learnings.jsonl"),
|
||||
JSON.stringify({ ts: "2026-09-01T00:00:00Z", skill: "review", type: "pitfall", key: "k", insight: "Check the lock first.", confidence: 8 }) + "\n",
|
||||
);
|
||||
|
||||
// Two attributable Claude Code sessions: one recent, one 200 days old.
|
||||
const repo = join(home, "work", "acme");
|
||||
mkdirSync(repo, { recursive: true });
|
||||
const git = (...args: string[]) => spawnSync("git", ["-C", repo, ...args], { encoding: "utf-8", timeout: 30_000 });
|
||||
git("init", "-q");
|
||||
git("remote", "add", "origin", "https://github.com/acme/app.git");
|
||||
git("config", "--local", "url.https://github.com/acme/app.git.insteadOf", "https://github.com/acme/app.git");
|
||||
const sessionDir = join(home, ".claude", "projects", "work-acme");
|
||||
mkdirSync(sessionDir, { recursive: true });
|
||||
const session = (id: string, text: string) =>
|
||||
`{"type":"user","message":{"role":"user","content":"${text}"},"timestamp":"2026-09-01T00:00:00Z","cwd":"${repo}","sessionId":"${id}"}\n` +
|
||||
`{"type":"assistant","message":{"role":"assistant","content":"ok"},"timestamp":"2026-09-01T00:00:01Z"}\n`;
|
||||
writeFileSync(join(sessionDir, "recent0001.jsonl"), session("recent0001", "recent session"));
|
||||
const old = join(sessionDir, "old000000001.jsonl");
|
||||
writeFileSync(old, session("old000000001", "old session"));
|
||||
const oldTime = new Date(Date.now() - 200 * DAY_MS);
|
||||
utimesSync(old, oldTime, oldTime);
|
||||
|
||||
// Stub gbrain. With GBRAIN_PAUSE_DIR set, `sources list` (the first gbrain
|
||||
// call of the memory stage) waits until the test releases it.
|
||||
const sources = opts.federated
|
||||
? `{"sources":[{"id":"gstack-artifacts-acme","local_path":"${worktree}","federated":true,"page_count":12}]}`
|
||||
: `{"sources":[]}`;
|
||||
writeFileSync(join(bindir, "gbrain"), `#!/bin/sh
|
||||
printf '%s\\n' "$*" >> "${calls}"
|
||||
case "$1" in
|
||||
--version) echo "gbrain 0.60.28.0" ;;
|
||||
--help) printf 'Commands:\\n import <dir> Import markdown directory\\n' ;;
|
||||
sources)
|
||||
if [ -n "$GBRAIN_PAUSE_DIR" ]; then
|
||||
touch "$GBRAIN_PAUSE_DIR/paused"
|
||||
i=0
|
||||
while [ ! -e "$GBRAIN_PAUSE_DIR/release" ] && [ $i -lt 300 ]; do sleep 0.1; i=$((i+1)); done
|
||||
fi
|
||||
echo '${sources}' ;;
|
||||
import)
|
||||
( cd "$2" && find . -name '*.md' -type f | sort ) > "${stagedList}"
|
||||
n=$(wc -l < "${stagedList}" | tr -d ' ')
|
||||
echo "{\\"status\\":\\"success\\",\\"imported\\":$n,\\"skipped\\":0,\\"errors\\":0,\\"total_files\\":$n}"
|
||||
;;
|
||||
*) echo "unexpected gbrain $*" >&2; exit 1 ;;
|
||||
esac
|
||||
`);
|
||||
chmodSync(join(bindir, "gbrain"), 0o755);
|
||||
return {
|
||||
home,
|
||||
gstackHome,
|
||||
stagedList,
|
||||
calls,
|
||||
env: {
|
||||
HOME: home,
|
||||
GSTACK_HOME: gstackHome,
|
||||
GBRAIN_HOME: "",
|
||||
GSTACK_MEMORY_INGEST_SOURCES: "",
|
||||
GIT_CONFIG_NOSYSTEM: "1",
|
||||
GIT_CONFIG_GLOBAL: join(home, ".gitconfig"),
|
||||
PATH: `${bindir}:${process.env.PATH || ""}`,
|
||||
},
|
||||
cleanup: () => rmSync(home, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
function runSync(sb: Sandbox, args: string[], env: Record<string, string> = {}) {
|
||||
return spawnSync("bun", [SYNC, "--no-code", "--no-brain-sync", ...args], {
|
||||
encoding: "utf-8",
|
||||
timeout: 60_000,
|
||||
cwd: sb.home,
|
||||
env: { ...process.env, ...sb.env, ...env },
|
||||
});
|
||||
}
|
||||
|
||||
function staged(sb: Sandbox): string {
|
||||
return existsSync(sb.stagedList) ? readFileSync(sb.stagedList, "utf-8") : "";
|
||||
}
|
||||
|
||||
function memoryStage(sb: Sandbox): { ok: boolean; memory_sources?: string[]; transcript_consent?: Record<string, unknown> } {
|
||||
const state = JSON.parse(readFileSync(join(sb.gstackHome, ".gbrain-sync-state.json"), "utf-8"));
|
||||
return state.last_stages.find((s: { name: string }) => s.name === "memory");
|
||||
}
|
||||
|
||||
const transcripts = (sb: Sandbox) => staged(sb).split("\n").filter((l) => l.includes("transcripts/"));
|
||||
|
||||
describe.skipIf(process.platform === "win32")("memory stage transcript consent", () => {
|
||||
test("absent key: transcripts skipped, other memory syncs, notice prints under --quiet", () => {
|
||||
const sb = sandbox();
|
||||
try {
|
||||
const r = runSync(sb, ["--incremental", "--quiet"]);
|
||||
expect(r.status).toBe(0);
|
||||
expect(transcripts(sb)).toEqual([]);
|
||||
expect(staged(sb)).toMatch(/learning/);
|
||||
expect(staged(sb)).toMatch(/ceo-plan/);
|
||||
expect(r.stderr).toContain(notice("not set"));
|
||||
const stage = memoryStage(sb);
|
||||
expect(stage.memory_sources).not.toContain("transcript");
|
||||
expect(stage.transcript_consent).toEqual({ mode: "not-set", window: null, skip_reason: "not-set" });
|
||||
} finally {
|
||||
sb.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("stored off: skipped; neutral line only without --quiet", () => {
|
||||
const sb = sandbox({ mode: "off" });
|
||||
try {
|
||||
const loud = runSync(sb, ["--incremental"]);
|
||||
expect(loud.status).toBe(0);
|
||||
expect(transcripts(sb)).toEqual([]);
|
||||
expect(loud.stderr).toContain("gbrain-sync: transcripts off (your choice)");
|
||||
expect(loud.stderr).not.toContain("transcripts skipped");
|
||||
const quiet = runSync(sb, ["--incremental", "--quiet"]);
|
||||
expect(quiet.status).toBe(0);
|
||||
expect(quiet.stderr).not.toContain("gbrain-sync: transcripts");
|
||||
expect(memoryStage(sb).transcript_consent).toEqual({ mode: "off", window: null, skip_reason: "off" });
|
||||
} finally {
|
||||
sb.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("legacy values are not consent", () => {
|
||||
for (const mode of [" A ", "b", "D", "incremental"]) {
|
||||
const sb = sandbox({ mode });
|
||||
try {
|
||||
const r = runSync(sb, ["--full", "--no-dream", "--quiet"]);
|
||||
expect(r.status).toBe(0);
|
||||
expect(transcripts(sb)).toEqual([]);
|
||||
expect(staged(sb)).toMatch(/learning/);
|
||||
expect(r.stderr).toContain(notice(mode.trim()));
|
||||
expect(memoryStage(sb).transcript_consent).toMatchObject({ mode: "legacy", skip_reason: "legacy" });
|
||||
} finally {
|
||||
sb.cleanup();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("recent: the 90-day window, on --full too", () => {
|
||||
const sb = sandbox({ mode: "recent" });
|
||||
try {
|
||||
const r = runSync(sb, ["--full", "--no-dream", "--quiet"]);
|
||||
expect(r.status).toBe(0);
|
||||
expect(transcripts(sb).join("\n")).toMatch(/recent0001/);
|
||||
expect(transcripts(sb).join("\n")).not.toMatch(/old00000000/);
|
||||
expect(r.stderr).not.toContain("gbrain-sync: transcripts");
|
||||
expect(memoryStage(sb).transcript_consent).toEqual({ mode: "recent", window: "recent" });
|
||||
} finally {
|
||||
sb.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("all: --full walks all history", () => {
|
||||
const sb = sandbox({ mode: "all" });
|
||||
try {
|
||||
const r = runSync(sb, ["--full", "--no-dream", "--quiet"]);
|
||||
expect(r.status).toBe(0);
|
||||
expect(transcripts(sb).join("\n")).toMatch(/recent0001/);
|
||||
expect(transcripts(sb).join("\n")).toMatch(/old00000000/);
|
||||
expect(memoryStage(sb).transcript_consent).toEqual({ mode: "all", window: "all" });
|
||||
} finally {
|
||||
sb.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("an explicit list naming transcript overrides an absent key; the notice still prints", () => {
|
||||
for (const [args, env, why] of [
|
||||
[["--sources", "transcript,learning"], {}, "--sources"],
|
||||
[[], { GSTACK_MEMORY_INGEST_SOURCES: "transcript" }, "GSTACK_MEMORY_INGEST_SOURCES"],
|
||||
] as const) {
|
||||
const sb = sandbox();
|
||||
try {
|
||||
const r = runSync(sb, ["--incremental", "--quiet", ...args], { ...env });
|
||||
expect(r.status).toBe(0);
|
||||
expect(transcripts(sb).join("\n")).toMatch(/recent0001/);
|
||||
expect(r.stderr).toContain(`gbrain-sync: transcripts ingested because ${why} names transcript (transcript_ingest_mode=not set). ${HINT}`);
|
||||
expect(memoryStage(sb).transcript_consent).toEqual({ mode: "not-set", window: null });
|
||||
} finally {
|
||||
sb.cleanup();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("--sources all, an empty env list and an invalid-only list do not override", () => {
|
||||
for (const [args, env] of [
|
||||
[["--sources", "all"], {}],
|
||||
[[], { GSTACK_MEMORY_INGEST_SOURCES: "" }],
|
||||
[["--sources", "bogus"], {}],
|
||||
[[], { GSTACK_MEMORY_INGEST_SOURCES: "nope" }],
|
||||
] as const) {
|
||||
const sb = sandbox();
|
||||
try {
|
||||
const r = runSync(sb, ["--incremental", "--quiet", ...args], { ...env });
|
||||
expect(r.status).toBe(0);
|
||||
expect(transcripts(sb)).toEqual([]);
|
||||
expect(staged(sb)).toMatch(/learning/);
|
||||
expect(r.stderr).toContain(notice("not set"));
|
||||
} finally {
|
||||
sb.cleanup();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("federated source registered + absent key: no curated pages, no transcripts", () => {
|
||||
const sb = sandbox({ federated: true });
|
||||
try {
|
||||
const r = runSync(sb, ["--incremental", "--quiet"]);
|
||||
expect(r.status).toBe(0);
|
||||
expect(staged(sb)).toMatch(/learning/);
|
||||
expect(staged(sb)).not.toMatch(/ceo-plan/);
|
||||
expect(transcripts(sb)).toEqual([]);
|
||||
expect(memoryStage(sb).memory_sources).toEqual(
|
||||
MEMORY_INGEST_TYPES.filter((t) => !["transcript", "ceo-plan", "design-doc"].includes(t)),
|
||||
);
|
||||
} finally {
|
||||
sb.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a config change during the run does not add transcripts to it", async () => {
|
||||
const sb = sandbox();
|
||||
const pause = join(sb.home, "pause");
|
||||
mkdirSync(pause);
|
||||
try {
|
||||
const child = spawn("bun", [SYNC, "--no-code", "--no-brain-sync", "--incremental", "--quiet"], {
|
||||
cwd: sb.home,
|
||||
env: { ...process.env, ...sb.env, GBRAIN_PAUSE_DIR: pause },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (d) => (stderr += d));
|
||||
child.stdout.resume();
|
||||
const exited = new Promise<number | null>((resolve) => child.on("exit", resolve));
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (!existsSync(join(pause, "paused")) && Date.now() < deadline) await Bun.sleep(50);
|
||||
expect(existsSync(join(pause, "paused"))).toBe(true);
|
||||
writeFileSync(join(sb.gstackHome, "config.yaml"), "transcript_ingest_mode: all\n");
|
||||
writeFileSync(join(pause, "release"), "");
|
||||
expect(await exited).toBe(0);
|
||||
expect(transcripts(sb)).toEqual([]);
|
||||
expect(staged(sb)).toMatch(/learning/);
|
||||
expect(stderr).toContain(notice("not set"));
|
||||
expect(memoryStage(sb).transcript_consent).toMatchObject({ mode: "not-set" });
|
||||
} finally {
|
||||
sb.cleanup();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
describe("checkpoint resume requires the same consent policy", () => {
|
||||
function checkpoint(sb: Sandbox, previous: { memory_sources: string[]; transcript_consent?: Record<string, unknown> }): void {
|
||||
const stagingDir = join(sb.gstackHome, ".staging-ingest-1-1");
|
||||
mkdirSync(join(stagingDir, "projects"), { recursive: true });
|
||||
writeFileSync(join(stagingDir, ".gstack-staging"), "");
|
||||
writeFileSync(join(stagingDir, "projects", "stale-page.md"), "# stale\n");
|
||||
writeFileSync(join(sb.home, ".gbrain", "import-checkpoint.json"), JSON.stringify({ dir: stagingDir, processedIndex: 0, totalFiles: 1 }));
|
||||
writeFileSync(join(sb.gstackHome, ".gbrain-sync-state.json"), JSON.stringify({
|
||||
schema_version: 1,
|
||||
last_writer: "gstack-gbrain-sync",
|
||||
last_stages: [{ name: "memory", ran: true, ok: false, duration_ms: 1, summary: "timeout", ...previous }],
|
||||
}));
|
||||
}
|
||||
|
||||
test("same types and policy resume the checkpoint", () => {
|
||||
const sb = sandbox({ mode: "recent" });
|
||||
try {
|
||||
checkpoint(sb, { memory_sources: [...MEMORY_INGEST_TYPES], transcript_consent: { mode: "recent", window: "recent" } });
|
||||
// The fixture's staging dir is not a real interrupted import, so the
|
||||
// import's page accounting fails afterwards; the decision is what counts.
|
||||
const r = runSync(sb, ["--incremental", "--quiet"]);
|
||||
expect(r.stderr).toContain("resuming from gbrain checkpoint");
|
||||
expect(r.stderr).not.toContain("transcript consent changed");
|
||||
expect(staged(sb)).toContain("stale-page");
|
||||
} finally {
|
||||
sb.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("recent → all restages once and says so", () => {
|
||||
const sb = sandbox({ mode: "all" });
|
||||
try {
|
||||
checkpoint(sb, { memory_sources: [...MEMORY_INGEST_TYPES], transcript_consent: { mode: "recent", window: "recent" } });
|
||||
const r = runSync(sb, ["--incremental", "--quiet"]);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr).toContain("gbrain-sync: transcript consent changed since the interrupted import (recent → all)");
|
||||
expect(r.stderr).not.toContain("resuming from gbrain checkpoint");
|
||||
expect(staged(sb)).not.toContain("stale-page");
|
||||
} finally {
|
||||
sb.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("all → off restages, with the skip notice first", () => {
|
||||
const sb = sandbox({ mode: "off" });
|
||||
try {
|
||||
checkpoint(sb, { memory_sources: [...MEMORY_INGEST_TYPES], transcript_consent: { mode: "all", window: "all" } });
|
||||
const r = runSync(sb, ["--incremental"]);
|
||||
expect(r.status).toBe(0);
|
||||
const offAt = r.stderr.indexOf("gbrain-sync: transcripts off (your choice)");
|
||||
const restageAt = r.stderr.indexOf("gbrain-sync: transcript consent changed since the interrupted import (all → off)");
|
||||
expect(offAt).toBeGreaterThanOrEqual(0);
|
||||
expect(restageAt).toBeGreaterThan(offAt);
|
||||
expect(staged(sb)).not.toContain("stale-page");
|
||||
expect(transcripts(sb)).toEqual([]);
|
||||
} finally {
|
||||
sb.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("gstack-memory-ingest --bulk with the key absent ingests no transcripts", () => {
|
||||
const sb = sandbox();
|
||||
try {
|
||||
const r = spawnSync("bun", [INGEST, "--bulk"], { encoding: "utf-8", timeout: 60_000, cwd: sb.home, env: { ...process.env, ...sb.env } });
|
||||
expect(r.status).toBe(0);
|
||||
expect(transcripts(sb)).toEqual([]);
|
||||
expect(staged(sb)).toMatch(/learning/);
|
||||
expect(r.stderr).toContain("gstack-memory-ingest: transcripts skipped (transcript_ingest_mode=not set)");
|
||||
} finally {
|
||||
sb.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("gstack-memory-ingest --bulk with all walks all history", () => {
|
||||
const sb = sandbox({ mode: "all" });
|
||||
try {
|
||||
const r = spawnSync("bun", [INGEST, "--bulk", "--quiet"], { encoding: "utf-8", timeout: 60_000, cwd: sb.home, env: { ...process.env, ...sb.env } });
|
||||
expect(r.status).toBe(0);
|
||||
expect(transcripts(sb).join("\n")).toMatch(/old00000000/);
|
||||
} finally {
|
||||
sb.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -791,7 +791,22 @@ function isolateGitRemote(repo: string, url: string): void {
|
||||
expect(git("remote", "get-url", "origin")).toBe(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* These cases exercise transcript ingest after the user consented, so the
|
||||
* isolated state root gets `transcript_ingest_mode: recent` unless the case
|
||||
* set the key itself. The no-consent paths live in
|
||||
* gstack-gbrain-sync-transcript-mode.test.ts.
|
||||
*/
|
||||
function seedTranscriptConsent(gstackHome: string | undefined): void {
|
||||
if (!gstackHome) return;
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
const config = join(gstackHome, "config.yaml");
|
||||
const current = existsSync(config) ? readFileSync(config, "utf-8") : "";
|
||||
if (!/^transcript_ingest_mode:/m.test(current)) writeFileSync(config, `${current}transcript_ingest_mode: recent\n`);
|
||||
}
|
||||
|
||||
function runScript(args: string[], env: Record<string, string> = {}): { stdout: string; stderr: string; exitCode: number } {
|
||||
seedTranscriptConsent(env.GSTACK_HOME);
|
||||
const result = spawnSync("bun", [SCRIPT, ...args], {
|
||||
encoding: "utf-8",
|
||||
timeout: 30000,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* /open-gstack-browser must not kill a live browse daemon on its own.
|
||||
*
|
||||
* Step 0 probes with `$B status` (without auto-starting a daemon). No live
|
||||
* daemon → plain `$B connect`, which reaps orphans and refuses to replace a
|
||||
* busy daemon itself. A live daemon → AskUserQuestion, and only an explicit
|
||||
* yes runs `$B connect --force-restart`. Spawned or headless sessions leave it
|
||||
* running and print how to replace it. The skill never kills the PID recorded
|
||||
* in .gstack/browse.json by hand.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const TMPL = fs.readFileSync(path.join(ROOT, 'open-gstack-browser', 'SKILL.md.tmpl'), 'utf-8');
|
||||
// Printed with the resolved browse binary path in place of %s.
|
||||
const STOP_LINE = 'Live browse daemon left running. Run %s stop, then re-run /open-gstack-browser to replace it.';
|
||||
|
||||
const section = (from: string, to: string) => {
|
||||
const start = TMPL.indexOf(from);
|
||||
const end = TMPL.indexOf(to, start + from.length);
|
||||
expect(start).toBeGreaterThanOrEqual(0);
|
||||
expect(end).toBeGreaterThan(start);
|
||||
return TMPL.slice(start, end);
|
||||
};
|
||||
|
||||
describe('/open-gstack-browser daemon liveness', () => {
|
||||
test('no hand-rolled kill of the recorded daemon PID', () => {
|
||||
expect(TMPL).not.toMatch(/\bkill\b[^\n]*(_OLD_PID|\$\w*PID)/);
|
||||
expect(TMPL).not.toMatch(/kill -9/);
|
||||
expect(TMPL).not.toMatch(/grep -o '"pid":/);
|
||||
expect(TMPL).not.toMatch(/rm -f[^\n]*browse\.json/);
|
||||
});
|
||||
|
||||
test('Step 0 probes with $B status without auto-starting a daemon, before any connect', () => {
|
||||
const probeAt = TMPL.indexOf('BROWSE_NO_AUTOSTART=1 $B status');
|
||||
expect(probeAt).toBeGreaterThanOrEqual(0);
|
||||
expect(probeAt).toBeLessThan(TMPL.indexOf('$B connect'));
|
||||
});
|
||||
|
||||
test('--force-restart appears only in the explicit-yes branch', () => {
|
||||
const live = section('`DAEMON: live`', '## Step 2');
|
||||
const ask = live.indexOf('AskUserQuestion');
|
||||
expect(ask).toBeGreaterThanOrEqual(0);
|
||||
const occurrences = [...TMPL.matchAll(/--force-restart/g)].map((m) => m.index!);
|
||||
expect(occurrences.length).toBeGreaterThan(0);
|
||||
const liveStart = TMPL.indexOf('`DAEMON: live`');
|
||||
const askAbs = liveStart + ask;
|
||||
for (const at of occurrences) expect(at).toBeGreaterThan(askAbs);
|
||||
expect(live).toMatch(/only an explicit a runs[^\n]*--force-restart/i);
|
||||
expect(TMPL).toMatch(/after an explicit a[^\n]*\n+```bash\n\$B connect --force-restart\n```/i);
|
||||
});
|
||||
|
||||
test('the no-daemon branch runs plain connect and keeps the Chromium profile-lock cleanup', () => {
|
||||
const none = section('`DAEMON: none`', '`DAEMON: headed`');
|
||||
expect(none).toContain('plain `$B connect`');
|
||||
expect(none).toContain('SingletonLock SingletonSocket SingletonCookie');
|
||||
expect(none).not.toContain('--force-restart');
|
||||
});
|
||||
|
||||
test('spawned or headless sessions leave the daemon running and print the resolved-binary stop line', () => {
|
||||
const live = section('`DAEMON: live`', '## Step 1');
|
||||
expect(live).toMatch(/spawned[\s\S]{0,40}headless[\s\S]{0,40}do not ask/i);
|
||||
expect(live).toContain(`printf '${STOP_LINE}\\n' "$B"`);
|
||||
expect(live.indexOf(STOP_LINE)).toBeLessThan(live.indexOf('AskUserQuestion'));
|
||||
});
|
||||
});
|
||||
@@ -194,6 +194,8 @@ describe.skipIf(process.platform === "win32")("memory stage source selection (#2
|
||||
test("without a federated source, the default still ingests every type", () => {
|
||||
const sb = sandbox({ federated: false });
|
||||
try {
|
||||
// Transcripts join the default selection only with consent.
|
||||
writeFileSync(join(sb.gstackHome, "config.yaml"), "transcript_ingest_mode: recent\n");
|
||||
const r = runSync(sb, ["--incremental"]);
|
||||
expect(r.status).toBe(0);
|
||||
expect(ingestedTypes(sb)).toEqual(["ceo-plan", "design-doc", "learning"]);
|
||||
|
||||
@@ -74,10 +74,8 @@ describe('setup-gbrain templates (skeleton + sections) — bin invocation paths'
|
||||
);
|
||||
});
|
||||
|
||||
test('the silent-bulk mention uses bun run + .ts (R2, transcript-gate section)', () => {
|
||||
expect(transcriptGate).toContain(
|
||||
'bun run ~/.claude/skills/gstack/bin/gstack-memory-ingest.ts --bulk --quiet'
|
||||
);
|
||||
test('the gate never bulk-ingests before the user answers (transcript-gate section)', () => {
|
||||
expect(transcriptGate).not.toMatch(/gstack-memory-ingest\.ts --bulk/);
|
||||
});
|
||||
|
||||
test('the post-answer full-sync step uses bun run + .ts (R3, transcript-gate section)', () => {
|
||||
@@ -86,21 +84,28 @@ describe('setup-gbrain templates (skeleton + sections) — bin invocation paths'
|
||||
);
|
||||
});
|
||||
|
||||
test('the preamble-hook incremental-sync mention uses bun run + .ts (R4, transcript-gate section)', () => {
|
||||
expect(transcriptGate).toContain(
|
||||
'bun run ~/.claude/skills/gstack/bin/gstack-gbrain-sync.ts --incremental --quiet'
|
||||
);
|
||||
test('no skill-start hook is claimed to ingest transcripts; /sync-gbrain is named (transcript-gate section)', () => {
|
||||
expect(transcriptGate).not.toMatch(/every skill\s+start/i);
|
||||
expect(transcriptGate).toContain('/sync-gbrain');
|
||||
});
|
||||
|
||||
test('the neighboring gstack-config line in the post-answer block is untouched (bash script, no extension)', () => {
|
||||
expect(transcriptGate).toContain(
|
||||
'~/.claude/skills/gstack/bin/gstack-config set transcript_ingest_mode <choice>'
|
||||
);
|
||||
test('the post-answer gstack-config line stores a mode value, not the answer letter (bash script, no extension)', () => {
|
||||
expect(transcriptGate).toContain('~/.claude/skills/gstack/bin/gstack-config set transcript_ingest_mode');
|
||||
expect(transcriptGate).not.toContain('transcript_ingest_mode <choice>');
|
||||
});
|
||||
});
|
||||
|
||||
test('the prose-only mention naming the tool as a sentence subject is left unchanged (KTD4 — not a literal invocation; Step 10 verdict, skeleton)', () => {
|
||||
expect(tmpl).toContain('gstack-memory-ingest now persists staged transcripts to');
|
||||
});
|
||||
describe('transcript consent question is gated on `gstack-config has`', () => {
|
||||
const syncTmpl = fs.readFileSync(path.join(ROOT, 'sync-gbrain', 'SKILL.md.tmpl'), 'utf-8');
|
||||
for (const [name, text] of [['setup-gbrain transcript gate', transcriptGate], ['sync-gbrain', syncTmpl]] as const) {
|
||||
test(`${name} checks presence with has before asking, and never asks spawned or headless sessions`, () => {
|
||||
const hasAt = text.indexOf('gstack-config has transcript_ingest_mode');
|
||||
expect(hasAt).toBeGreaterThanOrEqual(0);
|
||||
expect(text.indexOf('AskUserQuestion', hasAt)).toBeGreaterThan(hasAt);
|
||||
for (const v of ['recent', 'all', 'off']) expect(text).toContain(`\`${v}\``);
|
||||
expect(text).toMatch(/spawned[\s\S]{0,40}headless[\s\S]{0,80}do not ask/i);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('setup-gbrain/memory.md — bin invocation paths', () => {
|
||||
|
||||
@@ -4,20 +4,14 @@ import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { execFileSync } from 'child_process';
|
||||
|
||||
// Tripwire for the pid/port extraction snippets in /open-gstack-browser.
|
||||
// Tripwire for the port extraction snippet in /open-gstack-browser.
|
||||
//
|
||||
// Step 0 (pre-flight cleanup) reads the stale daemon's pid out of
|
||||
// .gstack/browse.json to kill it; Step 2 reads the port back to tell the user
|
||||
// which one the Side Panel needs. Both used `grep -o '"pid":[0-9]*'`, which
|
||||
// cannot match: every writer of that file in browse/src/server.ts serializes
|
||||
// with `JSON.stringify(state, null, 2)`, so the real bytes are `"pid": 12060`
|
||||
// — colon, SPACE, digits.
|
||||
//
|
||||
// The failure was silent in the worst way. `_OLD_PID` came back empty, the
|
||||
// `kill` never ran, browse.json was deleted anyway, and the next `connect`
|
||||
// died with "existing daemon has different config (proxy/headed mismatch)"
|
||||
// — an error that points at proxy/headed flags, not at the cleanup that
|
||||
// no-opped. Observed 2026-08-28 against a daemon left over from a reboot.
|
||||
// Step 2 reads the port out of .gstack/browse.json to tell the user which one
|
||||
// the Side Panel needs. A `grep -o '"port":[0-9]*'` pattern cannot match:
|
||||
// every writer of that file in browse/src/server.ts serializes with
|
||||
// `JSON.stringify(state, null, 2)`, so the real bytes are `"port": 34567`
|
||||
// — colon, SPACE, digits. (The skill no longer reads the pid at all: the
|
||||
// browse CLI owns daemon liveness and replacement.)
|
||||
//
|
||||
// So this test does not match strings; it RUNS the snippets the skill tells
|
||||
// the agent to run, against a state file written exactly the way the server
|
||||
@@ -38,7 +32,7 @@ function writeStateFile(dir: string, pid: number, port: number): string {
|
||||
}
|
||||
|
||||
/** Pull the grep pipeline for `field` out of the skill prose and run it. */
|
||||
function extractViaSkill(source: string, field: 'pid' | 'port', stateFile: string): string {
|
||||
function extractViaSkill(source: string, field: 'port', stateFile: string): string {
|
||||
const line = source
|
||||
.split('\n')
|
||||
.find((l) => l.includes(`grep -o '"${field}":`));
|
||||
@@ -54,12 +48,11 @@ function extractViaSkill(source: string, field: 'pid' | 'port', stateFile: strin
|
||||
|
||||
describe('/open-gstack-browser state-file extraction', () => {
|
||||
for (const [label, file] of [['generated', SKILL], ['template', TMPL]] as const) {
|
||||
test(`${label}: pid and port survive the pretty-printed state file`, () => {
|
||||
test(`${label}: the port survives the pretty-printed state file`, () => {
|
||||
const source = fs.readFileSync(file, 'utf-8');
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-state-'));
|
||||
try {
|
||||
const stateFile = writeStateFile(dir, 12060, 34567);
|
||||
expect(extractViaSkill(source, 'pid', stateFile)).toBe('12060');
|
||||
expect(extractViaSkill(source, 'port', stateFile)).toBe('34567');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
|
||||
Reference in new issue
Block a user