diff --git a/test/fixtures/golden/claude-ship-SKILL.md b/test/fixtures/golden/claude-ship-SKILL.md index 27b785e5a..adf4e0933 100644 --- a/test/fixtures/golden/claude-ship-SKILL.md +++ b/test/fixtures/golden/claude-ship-SKILL.md @@ -113,7 +113,7 @@ In plan mode, allowed because they inform the plan: `$B`, `$D`, `codex exec`/`co ## Skill Invocation During Plan Mode -If the user invokes a skill in plan mode, the skill takes precedence over generic plan mode behavior. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; the first AskUserQuestion is the workflow entering plan mode, not a violation of it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If no variant is callable, fall back to writing the decision brief into the plan file as a `## Decisions to confirm` section + ExitPlanMode — never silently auto-decide. At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" execute. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode. +If the user invokes a skill in plan mode, the skill takes precedence over generic plan mode behavior. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; the first AskUserQuestion is the workflow entering plan mode, not a violation of it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If no variant is callable, the skill is BLOCKED — stop and report `BLOCKED — AskUserQuestion unavailable` per the AskUserQuestion Format rule. At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" execute. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode. If `PROACTIVE` is `"false"`, do not auto-invoke or proactively suggest skills. If a skill seems useful, ask: "I think /skillname might help here — want me to run it?" @@ -284,7 +284,7 @@ AI orchestrator (e.g., OpenClaw). In spawned sessions: **Rule:** if any `mcp__*__AskUserQuestion` variant is in your tool list, prefer it. Hosts may disable native AUQ via `--disallowedTools AskUserQuestion` (Conductor does, by default) and route through their MCP variant; calling native there silently fails. Same questions/options shape; same decision-brief format applies. -**Fallback when neither variant is callable:** in plan mode, write the decision brief into the plan file as a `## Decisions to confirm` section + ExitPlanMode (the native "Ready to execute?" surfaces it). Outside plan mode, output the brief as prose and stop. **Never silently auto-decide** — only `/plan-tune` AUTO_DECIDE opt-ins authorize auto-picking. +**If no AskUserQuestion variant appears in your tool list, this skill is BLOCKED.** Stop, report `BLOCKED — AskUserQuestion unavailable`, and wait for the user. Do not write decisions to the plan file as a substitute, do not emit them as prose and stop, and do not silently auto-decide (only `/plan-tune` AUTO_DECIDE opt-ins authorize auto-picking). ### Format diff --git a/test/fixtures/golden/codex-ship-SKILL.md b/test/fixtures/golden/codex-ship-SKILL.md index 06f90461a..d09e39e98 100644 --- a/test/fixtures/golden/codex-ship-SKILL.md +++ b/test/fixtures/golden/codex-ship-SKILL.md @@ -102,7 +102,7 @@ In plan mode, allowed because they inform the plan: `$B`, `$D`, `codex exec`/`co ## Skill Invocation During Plan Mode -If the user invokes a skill in plan mode, the skill takes precedence over generic plan mode behavior. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; the first AskUserQuestion is the workflow entering plan mode, not a violation of it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If no variant is callable, fall back to writing the decision brief into the plan file as a `## Decisions to confirm` section + ExitPlanMode — never silently auto-decide. At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" execute. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode. +If the user invokes a skill in plan mode, the skill takes precedence over generic plan mode behavior. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; the first AskUserQuestion is the workflow entering plan mode, not a violation of it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If no variant is callable, the skill is BLOCKED — stop and report `BLOCKED — AskUserQuestion unavailable` per the AskUserQuestion Format rule. At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" execute. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode. If `PROACTIVE` is `"false"`, do not auto-invoke or proactively suggest skills. If a skill seems useful, ask: "I think /skillname might help here — want me to run it?" @@ -273,7 +273,7 @@ AI orchestrator (e.g., OpenClaw). In spawned sessions: **Rule:** if any `mcp__*__AskUserQuestion` variant is in your tool list, prefer it. Hosts may disable native AUQ via `--disallowedTools AskUserQuestion` (Conductor does, by default) and route through their MCP variant; calling native there silently fails. Same questions/options shape; same decision-brief format applies. -**Fallback when neither variant is callable:** in plan mode, write the decision brief into the plan file as a `## Decisions to confirm` section + ExitPlanMode (the native "Ready to execute?" surfaces it). Outside plan mode, output the brief as prose and stop. **Never silently auto-decide** — only `/plan-tune` AUTO_DECIDE opt-ins authorize auto-picking. +**If no AskUserQuestion variant appears in your tool list, this skill is BLOCKED.** Stop, report `BLOCKED — AskUserQuestion unavailable`, and wait for the user. Do not write decisions to the plan file as a substitute, do not emit them as prose and stop, and do not silently auto-decide (only `/plan-tune` AUTO_DECIDE opt-ins authorize auto-picking). ### Format diff --git a/test/fixtures/golden/factory-ship-SKILL.md b/test/fixtures/golden/factory-ship-SKILL.md index 71ae2119f..ec849bcce 100644 --- a/test/fixtures/golden/factory-ship-SKILL.md +++ b/test/fixtures/golden/factory-ship-SKILL.md @@ -104,7 +104,7 @@ In plan mode, allowed because they inform the plan: `$B`, `$D`, `codex exec`/`co ## Skill Invocation During Plan Mode -If the user invokes a skill in plan mode, the skill takes precedence over generic plan mode behavior. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; the first AskUserQuestion is the workflow entering plan mode, not a violation of it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If no variant is callable, fall back to writing the decision brief into the plan file as a `## Decisions to confirm` section + ExitPlanMode — never silently auto-decide. At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" execute. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode. +If the user invokes a skill in plan mode, the skill takes precedence over generic plan mode behavior. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; the first AskUserQuestion is the workflow entering plan mode, not a violation of it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If no variant is callable, the skill is BLOCKED — stop and report `BLOCKED — AskUserQuestion unavailable` per the AskUserQuestion Format rule. At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" execute. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode. If `PROACTIVE` is `"false"`, do not auto-invoke or proactively suggest skills. If a skill seems useful, ask: "I think /skillname might help here — want me to run it?" @@ -275,7 +275,7 @@ AI orchestrator (e.g., OpenClaw). In spawned sessions: **Rule:** if any `mcp__*__AskUserQuestion` variant is in your tool list, prefer it. Hosts may disable native AUQ via `--disallowedTools AskUserQuestion` (Conductor does, by default) and route through their MCP variant; calling native there silently fails. Same questions/options shape; same decision-brief format applies. -**Fallback when neither variant is callable:** in plan mode, write the decision brief into the plan file as a `## Decisions to confirm` section + ExitPlanMode (the native "Ready to execute?" surfaces it). Outside plan mode, output the brief as prose and stop. **Never silently auto-decide** — only `/plan-tune` AUTO_DECIDE opt-ins authorize auto-picking. +**If no AskUserQuestion variant appears in your tool list, this skill is BLOCKED.** Stop, report `BLOCKED — AskUserQuestion unavailable`, and wait for the user. Do not write decisions to the plan file as a substitute, do not emit them as prose and stop, and do not silently auto-decide (only `/plan-tune` AUTO_DECIDE opt-ins authorize auto-picking). ### Format diff --git a/test/gstack-memory-ingest.test.ts b/test/gstack-memory-ingest.test.ts index fec698271..cb02cbd4b 100644 --- a/test/gstack-memory-ingest.test.ts +++ b/test/gstack-memory-ingest.test.ts @@ -312,54 +312,101 @@ describe("gstack-memory-ingest --limit", () => { }); }); -// ── Writer regression: gbrain v0.27+ uses `put`, not `put_page` ─────────── +// ── Writer regression: batch-import via `gbrain import ` ───────────── /** * Stand up a fake `gbrain` shim on PATH that: - * - advertises `put` in `--help` output (so gbrainAvailable() passes) - * - records `put ` invocations + their stdin to a log - * - rejects `put_page` with a non-zero exit, mimicking real gbrain v0.27+ + * - advertises `import` in `--help` output (gbrainAvailable() passes) + * - records `import ` invocations, args, and a sample of staged files + * - emits a valid `--json` summary on stdout (status, imported, etc.) + * - optionally drops failures to a sync-failures.jsonl path (HOME/.gbrain/) * - * If the writer ever regresses to the legacy flag-form, the bulk pass will - * report 0 writes and the assertion on `Wrote: 1` will fail loudly. + * Architecture being verified (post plan-eng-review + Codex outside-voice): + * - new code uses `gbrain import --no-embed --json` ONE time, + * not `gbrain put ` per file. The fixture would catch a regression + * to the legacy per-file loop because (a) `put` is no longer advertised, + * so gbrainAvailable() returns false; (b) we assert the recorded args + * include `import` and the dir argument. */ -function installFakeGbrain(home: string): { binDir: string; logFile: string; stdinFile: string } { +function installFakeGbrain( + home: string, + opts: { failingPaths?: string[] } = {}, +): { binDir: string; logFile: string; argsFile: string; stagingListFile: string } { const binDir = join(home, "fake-bin"); mkdirSync(binDir, { recursive: true }); const logFile = join(home, "gbrain-calls.log"); - const stdinFile = join(home, "gbrain-stdin.log"); + const argsFile = join(home, "gbrain-args.log"); + const stagingListFile = join(home, "gbrain-staging-list.log"); + // Bash-side: when failingPaths is set, append matching JSONL entries to + // ~/.gbrain/sync-failures.jsonl so D7's readNewFailures can read them. + const failingList = (opts.failingPaths || []).join("|"); const script = `#!/usr/bin/env bash set -euo pipefail LOG="${logFile}" -STDIN_LOG="${stdinFile}" +ARGS_LOG="${argsFile}" +STAGING_LIST="${stagingListFile}" +FAILING_LIST="${failingList}" case "\${1:-}" in --help|-h) cat < [options] Commands: - put Write a page (content via stdin, YAML frontmatter for metadata) + import Import markdown directory (batch, content-addressed) search Keyword search across pages ask Hybrid semantic + keyword query EOF exit 0 ;; - put) - if [ "\${2:-}" = "--help" ]; then - echo "Usage: gbrain put " - exit 0 - fi - echo "put \${2:-}" >> "\$LOG" + import) + DIR="\${2:-}" + NO_EMBED=0 + JSON=0 + shift 2 || true + for arg in "\$@"; do + case "\$arg" in + --no-embed) NO_EMBED=1 ;; + --json) JSON=1 ;; + esac + done + echo "import \$DIR" >> "\$LOG" { - echo "--- slug=\${2:-} ---" - cat - echo - } >> "\$STDIN_LOG" + echo "dir=\$DIR no_embed=\$NO_EMBED json=\$JSON" + } >> "\$ARGS_LOG" + # Capture file tree from staging dir for assertion-on-shape later. + if [ -d "\$DIR" ]; then + ( cd "\$DIR" && find . -type f | sort ) > "\$STAGING_LIST" 2>/dev/null || true + fi + # If failingPaths configured, drop fake entries to sync-failures.jsonl + # (mtime byte-offset snapshot lets the ingest's readNewFailures pick them up). + if [ -n "\$FAILING_LIST" ]; then + mkdir -p "\${HOME}/.gbrain" + IFS='|' read -ra FAIL_PATHS <<< "\$FAILING_LIST" + for p in "\${FAIL_PATHS[@]}"; do + echo "{\\"path\\":\\"\$p\\",\\"error\\":\\"File too large\\",\\"code\\":\\"FILE_TOO_LARGE\\",\\"commit\\":\\"\\",\\"ts\\":\\"2026-05-09T22:00:00Z\\"}" >> "\${HOME}/.gbrain/sync-failures.jsonl" + done + fi + # Count files in staging dir for the imported count. + if [ -d "\$DIR" ]; then + TOTAL=\$(find "\$DIR" -name "*.md" -type f | wc -l | tr -d ' ') + else + TOTAL=0 + fi + ERRORS=0 + if [ -n "\$FAILING_LIST" ]; then + ERRORS=\$(echo "\$FAILING_LIST" | tr '|' '\\n' | wc -l | tr -d ' ') + fi + IMPORTED=\$((TOTAL - ERRORS)) + if [ \$JSON -eq 1 ]; then + echo "{\\"status\\":\\"success\\",\\"duration_s\\":0.1,\\"imported\\":\$IMPORTED,\\"skipped\\":0,\\"errors\\":\$ERRORS,\\"chunks\\":\$IMPORTED,\\"total_files\\":\$TOTAL}" + fi exit 0 ;; - put_page|put-page) - echo "Unknown command: \$1" >&2 - exit 2 + put|put_page|put-page) + # If new ingest code ever regresses to per-file puts, fail loudly so the + # test signals a real architectural regression. + echo "Unexpected legacy command: \$1" >&2 + exit 99 ;; *) echo "Unknown command: \${1:-}" >&2 @@ -370,18 +417,18 @@ esac const binPath = join(binDir, "gbrain"); writeFileSync(binPath, script, "utf-8"); chmodSync(binPath, 0o755); - return { binDir, logFile, stdinFile }; + return { binDir, logFile, argsFile, stagingListFile }; } -describe("gstack-memory-ingest writer (gbrain v0.27+ `put` interface)", () => { - it("invokes `gbrain put ` with stdin body, not legacy `put_page`", () => { +describe("gstack-memory-ingest writer (gbrain v0.20+ batch `import` interface)", () => { + it("invokes `gbrain import --no-embed --json` exactly once with hierarchical staging", () => { const home = makeTestHome(); const gstackHome = join(home, ".gstack"); mkdirSync(gstackHome, { recursive: true }); - const { binDir, logFile, stdinFile } = installFakeGbrain(home); + const { binDir, logFile, argsFile, stagingListFile } = installFakeGbrain(home); - // Single Claude Code session fixture. --include-unattributed lets it write - // even though there's no resolvable git remote in /tmp. + // Single Claude Code session fixture. --include-unattributed lets it + // write even though there's no resolvable git remote in /tmp. const session = `{"type":"user","message":{"role":"user","content":"hi"},"timestamp":"2026-05-01T00:00:00Z","cwd":"/tmp/foo"}\n` + `{"type":"assistant","message":{"role":"assistant","content":"hello"},"timestamp":"2026-05-01T00:00:01Z"}\n`; @@ -396,38 +443,163 @@ describe("gstack-memory-ingest writer (gbrain v0.27+ `put` interface)", () => { expect(r.exitCode).toBe(0); expect(existsSync(logFile)).toBe(true); - const calls = readFileSync(logFile, "utf-8"); - expect(calls).toContain("put "); - expect(calls).not.toContain("put_page"); + // Verify gbrain was called exactly ONCE with import, not per-file put. + const calls = readFileSync(logFile, "utf-8").trim().split("\n").filter(Boolean); + expect(calls.length).toBe(1); + expect(calls[0]).toMatch(/^import\s+\/.+\/\.staging-ingest-\d+-\d+$/); - // Body should ride stdin and carry frontmatter that gbrain can parse. - // The transcript builder prepends its own frontmatter (agent, session_id, - // etc.) but does NOT include title/type/tags — the writer injects those - // into the existing frontmatter so gbrain pages list/search/filter - // actually surface the page. Asserting all three guards against the - // exact regression that landed in v1.26.0.0 (writer ignored these fields - // entirely; pages landed empty-titled, un-typed, un-tagged). - const stdin = readFileSync(stdinFile, "utf-8"); - expect(stdin).toContain("---"); - expect(stdin).toMatch(/agent:\s+claude-code/); - expect(stdin).toMatch(/title:\s/); - expect(stdin).toMatch(/type:\s+transcript/); - expect(stdin).toMatch(/tags:/); + // Verify args: --no-embed and --json both present. + const argDump = readFileSync(argsFile, "utf-8"); + expect(argDump).toMatch(/no_embed=1/); + expect(argDump).toMatch(/json=1/); - rmSync(home, { recursive: true, force: true }); + // D1 regression: staged file lives in a slug-shaped subdirectory tree + // ("transcripts/claude-code/_unattributed/..."), not flat at the staging + // dir root. If writeStaged ever regresses to flat layout, this fails. + const stagedList = readFileSync(stagingListFile, "utf-8"); + expect(stagedList).toMatch(/^\.\/transcripts\/claude-code\/.+\.md$/m); }); - it("fails fast when gbrain CLI is missing the `put` subcommand", () => { + it("injects title/type/tags into the staged page's YAML frontmatter", () => { const home = makeTestHome(); const gstackHome = join(home, ".gstack"); mkdirSync(gstackHome, { recursive: true }); - // Fake gbrain that ONLY advertises legacy `put_page` (no `put`). + // This shim sleeps long enough to let us read the staging dir mid-run. + // Easier path: intercept by copying the staging dir before gbrain exits. + const binDir = join(home, "fake-bin"); + mkdirSync(binDir, { recursive: true }); + const stagingCopy = join(home, "staging-copy"); + const script = `#!/usr/bin/env bash +case "\${1:-}" in + --help|-h) echo "Usage: gbrain "; echo "Commands:"; echo " import Import"; exit 0 ;; + import) + DIR="\${2:-}" + cp -R "\$DIR" "${stagingCopy}" 2>/dev/null || true + # Emit valid --json output + if [[ " \$* " == *" --json "* ]]; then + echo '{"status":"success","duration_s":0.1,"imported":1,"skipped":0,"errors":0,"chunks":1,"total_files":1}' + fi + exit 0 ;; + *) echo "unknown"; exit 2 ;; +esac +`; + const binPath = join(binDir, "gbrain"); + writeFileSync(binPath, script, "utf-8"); + chmodSync(binPath, 0o755); + + const session = + `{"type":"user","message":{"role":"user","content":"hi"},"timestamp":"2026-05-01T00:00:00Z","cwd":"/tmp/foo"}\n` + + `{"type":"assistant","message":{"role":"assistant","content":"hello"},"timestamp":"2026-05-01T00:00:01Z"}\n`; + writeClaudeCodeSession(home, "tmp-foo", "abc123", session); + + const r = runScript(["--bulk", "--include-unattributed", "--quiet"], { + HOME: home, + GSTACK_HOME: gstackHome, + PATH: `${binDir}:${process.env.PATH || ""}`, + }); + expect(r.exitCode).toBe(0); + expect(existsSync(stagingCopy)).toBe(true); + + // Find the staged .md file; assert frontmatter has title/type/tags. + // (The exact slug path varies with the staging dir generation, so we + // walk to find a .md and read its head.) + const findMd = spawnSync("find", [stagingCopy, "-name", "*.md", "-type", "f"], { + encoding: "utf-8", + }); + const mdPaths = (findMd.stdout || "").trim().split("\n").filter(Boolean); + expect(mdPaths.length).toBeGreaterThan(0); + const body = readFileSync(mdPaths[0], "utf-8"); + expect(body).toContain("---"); + expect(body).toMatch(/title:\s/); + expect(body).toMatch(/type:\s+transcript/); + expect(body).toMatch(/tags:/); + + rmSync(home, { recursive: true, force: true }); + }); + + it("D7: files listed in ~/.gbrain/sync-failures.jsonl are NOT recorded in state", () => { + const home = makeTestHome(); + const gstackHome = join(home, ".gstack"); + mkdirSync(gstackHome, { recursive: true }); + + // Write TWO sessions so we can verify one lands and the other doesn't. + const sessionA = + `{"type":"user","message":{"role":"user","content":"a"},"timestamp":"2026-05-01T00:00:00Z","cwd":"/tmp/foo"}\n` + + `{"type":"assistant","message":{"role":"assistant","content":"a"},"timestamp":"2026-05-01T00:00:01Z"}\n`; + const sessionB = + `{"type":"user","message":{"role":"user","content":"b"},"timestamp":"2026-05-02T00:00:00Z","cwd":"/tmp/bar"}\n` + + `{"type":"assistant","message":{"role":"assistant","content":"b"},"timestamp":"2026-05-02T00:00:01Z"}\n`; + writeClaudeCodeSession(home, "tmp-foo", "aaaa", sessionA); + writeClaudeCodeSession(home, "tmp-bar", "bbbb", sessionB); + + // Configure fake gbrain to "fail" the second session's staged path. + // The staging-dir-relative path is "transcripts/claude-code/...bbbb.md" + // (Codex sessions take a different prefix). We use a wildcard via the + // last segment matching the session id. + // The fake matches a literal path against the staging-list it captures, + // but since we can't know the exact path ahead of time, we let the + // ingest run once normally, inspect the staging list, then set HOME + // .gbrain/sync-failures.jsonl manually. Simpler: cause the SHA-id + // session-id segment to be in the failing list directly — gbrain's + // failure record uses the staging-relative path. + // Easiest: write a sync-failures.jsonl pre-existing that we OVERWRITE + // after the ingest starts. To keep this deterministic without timing, + // we run a passthrough fake that itself writes the failure entry. + const binDir = join(home, "fake-bin"); + mkdirSync(binDir, { recursive: true }); + const script = `#!/usr/bin/env bash +case "\${1:-}" in + --help|-h) echo "Usage: gbrain"; echo "Commands:"; echo " import Import"; exit 0 ;; + import) + DIR="\${2:-}" + # Pick the SECOND .md found in the staging dir and mark it failed in + # ~/.gbrain/sync-failures.jsonl using the dir-relative path. The first + # one lands cleanly. + mkdir -p "\${HOME}/.gbrain" + REL=\$(cd "\$DIR" && find . -name "*.md" -type f | sed 's|^\\./||' | sort | tail -1) + if [ -n "\$REL" ]; then + echo "{\\"path\\":\\"\$REL\\",\\"error\\":\\"File too large\\",\\"code\\":\\"FILE_TOO_LARGE\\",\\"commit\\":\\"\\",\\"ts\\":\\"2026-05-09T22:00:00Z\\"}" >> "\${HOME}/.gbrain/sync-failures.jsonl" + fi + if [[ " \$* " == *" --json "* ]]; then + echo '{"status":"success","duration_s":0.1,"imported":1,"skipped":0,"errors":1,"chunks":1,"total_files":2}' + fi + exit 0 ;; + *) echo "unknown"; exit 2 ;; +esac +`; + const binPath = join(binDir, "gbrain"); + writeFileSync(binPath, script, "utf-8"); + chmodSync(binPath, 0o755); + + const r = runScript(["--bulk", "--include-unattributed", "--quiet"], { + HOME: home, + GSTACK_HOME: gstackHome, + PATH: `${binDir}:${process.env.PATH || ""}`, + }); + expect(r.exitCode).toBe(0); + + // State file should have exactly 1 session entry (the non-failed one). + const statePath = join(gstackHome, ".transcript-ingest-state.json"); + expect(existsSync(statePath)).toBe(true); + const state = JSON.parse(readFileSync(statePath, "utf-8")); + const sessionPaths = Object.keys(state.sessions || {}); + expect(sessionPaths.length).toBe(1); + + rmSync(home, { recursive: true, force: true }); + }); + + it("emits ERR with system_error and exits non-zero when gbrain CLI is missing the `import` subcommand", () => { + const home = makeTestHome(); + const gstackHome = join(home, ".gstack"); + mkdirSync(gstackHome, { recursive: true }); + + // Fake gbrain that advertises ONLY `put` (legacy) — no `import`. const binDir = join(home, "legacy-bin"); mkdirSync(binDir, { recursive: true }); const script = `#!/usr/bin/env bash case "\${1:-}" in - --help|-h) echo "Commands:"; echo " put_page Write a page (legacy)"; exit 0 ;; + --help|-h) echo "Commands:"; echo " put Write a page (legacy)"; exit 0 ;; *) echo "Unknown command: \$1" >&2; exit 2 ;; esac `; @@ -445,9 +617,69 @@ esac PATH: `${binDir}:${process.env.PATH || ""}`, }); - // Bulk completes (the script is per-page tolerant), but every page - // surfaces the missing-`put` error rather than the old "Unknown command". - expect(r.stderr + r.stdout).toMatch(/missing `put` subcommand|gbrain CLI not in PATH/); + // D6: system_error sets non-zero exit; orchestrator marks ERR. + expect(r.exitCode).toBe(1); + expect(r.stderr).toMatch(/\[memory-ingest\] ERR:.*missing `import` subcommand|gbrain CLI not in PATH/); + + rmSync(home, { recursive: true, force: true }); + }); + + it("--scan-secrets opt-in: skips files with gitleaks findings, lets clean files through", () => { + const home = makeTestHome(); + const gstackHome = join(home, ".gstack"); + mkdirSync(gstackHome, { recursive: true }); + const { binDir } = installFakeGbrain(home); + + // Fake gitleaks: prints a "finding" for any file whose path contains + // "dirty", clean for everything else. The fake-gbrain shim doesn't + // interfere — gitleaks is invoked from preparePages before staging. + const fakeGitleaksDir = join(home, "fake-gitleaks-bin"); + mkdirSync(fakeGitleaksDir, { recursive: true }); + const fakeGitleaks = `#!/usr/bin/env bash +# gitleaks detect --no-git --source --report-format json --report-path /dev/stdout --exit-code 0 +# We just need to emit a JSON findings array on stdout. Find the --source arg. +SRC="" +while [ "$#" -gt 0 ]; do + case "$1" in + --source) SRC="$2"; shift 2 ;; + *) shift ;; + esac +done +if echo "$SRC" | grep -q dirty; then + echo '[{"RuleID":"fake-rule","Description":"fake finding","StartLine":1,"Match":"REDACTED","Secret":"AKIAFAKEFAKEFAKE12345"}]' +else + echo '[]' +fi +exit 0 +`; + const gitleaksBin = join(fakeGitleaksDir, "gitleaks"); + writeFileSync(gitleaksBin, fakeGitleaks, "utf-8"); + chmodSync(gitleaksBin, 0o755); + + // Two sessions: one "clean" (filename has no "dirty"), one "dirty" + // (filename contains "dirty" so the fake gitleaks reports a finding). + const sessionA = + `{"type":"user","message":{"role":"user","content":"clean"},"timestamp":"2026-05-01T00:00:00Z","cwd":"/tmp/foo"}\n`; + const sessionB = + `{"type":"user","message":{"role":"user","content":"dirty"},"timestamp":"2026-05-02T00:00:00Z","cwd":"/tmp/bar"}\n`; + writeClaudeCodeSession(home, "tmp-foo", "cleansess123", sessionA); + // Force the path to contain the "dirty" marker. + writeClaudeCodeSession(home, "tmp-dirty-bar", "dirtysess456", sessionB); + + // Run with --scan-secrets enabled. Combine the fake gitleaks bin + // before fake-gbrain in PATH so both shims resolve. + const r = runScript(["--bulk", "--include-unattributed", "--scan-secrets"], { + HOME: home, + GSTACK_HOME: gstackHome, + PATH: `${fakeGitleaksDir}:${binDir}:${process.env.PATH || ""}`, + }); + + expect(r.exitCode).toBe(0); + // Bulk report shows skipped (secret-scan) >= 1 + expect(r.stdout).toMatch(/skipped \(secret-scan\):\s+1/); + // Stderr from the secret-scan match path (printed when !quiet) includes the dirty path's basename. + // Match generously: any occurrence of "secret-scan match" line. + expect(r.stderr + r.stdout).toMatch(/secret-scan match/); rmSync(home, { recursive: true, force: true }); });