Files
gstack/test/gstack-memory-ingest.test.ts
T
Garry TanandClaude Fable 5 e76f65a8da v1.77.0.0 feat: test-infrastructure overhaul wave 1 — matrix deletion, flake telemetry, sync-spawn wedge class extinct (#2746)
* fix: pin the claude CLI to an exact version in the CI image + tripwire

The image installed @anthropic-ai/claude-code UNPINNED and rebuilt weekly
'to pick up CLI updates' — while bun sat carefully pinned at 1.3.13 two RUN
lines above. The PTY harness screen-scrapes this CLI's TUI, and that drift
broke it three separate times (welcome-screen wedge on 2.1.233, skillify
HOME discovery on 2.1.237, guard/freeze hooks on 2.1.162), each debugged as
a flake first. Pin 2.1.251 (current latest), bump deliberately via a PR
that runs the PTY gate, and enforce with test/ci-image-cli-pin.test.ts:
any global npm install in Dockerfile.ci without an exact @X.Y.Z pin fails
the free suite. The weekly ci-image cron stays as a cheap tag self-heal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: stamp the claude CLI version into every eval-store run record

Three harness breakages were traced to claude-CLI TUI drift only after long
flake hunts, because no run record said which CLI it actually exercised.
EvalCollector now stamps claude_cli_version (claude --version, cached once
per process, 'unknown' when the binary is absent) into both partial and
finalized records — schema-additive optional field, no SCHEMA_VERSION bump.
Correlating a flake wave with a CLI release becomes a grep over
~/.gstack/projects/<slug>/evals/ instead of archaeology.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: give the spinning-shard kill test load headroom (30s -> 90s)

The test spawns and group-kills three real children (one a busy-loop
burning a full core) while five sibling shard processes compete for eight
vCPUs. Under full-suite load it blew bun's default 30s per-test ceiling at
30,009ms — while passing in isolation in 1.4s — and red the only required
lane. Every assertion in it is event-based (statuses, group-kill proof,
heartbeat lines); the sole latency claim is the <30s kill-deadline sanity
bound, which stays. Explicit 90s headroom, not a weakened oracle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: green-by-skip census — skip counts in the classifier, all-skipped labeling in the paid runner

bun's 'Ran N tests' line COUNTS skipped tests, so a codex/gemini shard
whose every test self-skipped (binary absent on the runner — true of every
CI runner today) exits 0, dodges the hollow-shard guard, and reads as
coverage in the weekly census. The classifier now parses bun's ' N skip' /
' N pass' recap lines; ShardOutcome carries skippedTests; formatSummary and
the fail-closed slices report label an all-skipped pass explicitly:
'all N tests SKIPPED — verified nothing'. Status stays 'passed' (external
service availability is host state, not a repo regression) but the census
can no longer mistake absence for coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor: extract composite actions for eval-lane setup; surviving lanes gain the fail-fast registry verification

'Fix bun temp' x3, 'Restore deps' x5, 'Seed claude interactive config' x3,
and 'Register gstack skills' x3 were byte-near-identical copies across the
legacy matrix, the sliced lane, and the periodic lane — and only the MATRIX
copy of register-skills carried the 19-line dangling-symlink + frontmatter
fail-fast loop written after a silent 'Unknown command' + 35-min-timeout
incident. Extract all four into .github/actions/ composites; the register
composite carries the verification loop (generalized over the skill list),
so the sliced and periodic lanes — the lanes that SURVIVE the matrix
deletion — now inherit the check they had silently dropped. Matrix-job
inline copies are left untouched: that job is deleted next.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: delete the legacy 17-row eval matrix — the sliced lane is the only paid lane

Every PR paid twice: the hand-enumerated matrix (18 test files, 22.6 min,
~$21 API measured on run 33263204465) ran serialized AHEAD of the strictly
superior sliced lane via 'needs: evals' — 35.5 min wall and ~2x paid spend
for the same diff. 14 of 17 rows carried no tier:, so periodic Opus
benchmarks leaked into every PR (the e2e-plan row alone: 12/12 tests,
21.7 min, $7.28 — the wall-clock bound of ALL of CI).

Parity receipt (static, pre-deletion): the sliced lane's gate census (49
files, derived from the runner itself) strictly contains all 18 matrix test
files, plus 31 files the matrix never ran. Pure deletion — one revert
restores it. The PR comment moved into slices-report (same '## E2E Evals'
upsert marker, now sourced from slice artifacts + carrying the fail-closed
reconciliation verdict). plan-slices loses the needs edge; the dead
workflow-level EVALS_TIER env goes with it.

test/evals-workflow-matrix.test.ts (and its KNOWN_MATRIX_GAPS /
KNOWN_TIER_UNSET burn-down ratchets — retired: the sliced census makes
'every gate file runs' true by construction) is rewritten as
test/evals-workflow-wiring.test.ts: matrix stays deleted, planner/executor/
report tier + slice-count agreement, both surviving lanes on the shared
register-skills composite with its fail-fast verification loop, PR comment
survival. Expected: PR eval wall 35.5 -> ~13 min, per-PR paid spend ~halved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: provider-runner timeouts kill the whole process GROUP; codex/gemini inherit the orphan-drain hardening

All three provider runners (claude/codex/gemini) killed only the direct
child on timeout: tool subprocesses the CLI spawned survived as orphans
holding our pipes open and burning shared API rate (observed: a 600s
timeout stretching past 1400s; a stalled run once burned a core for 15
hours). gstack-detach's watchdog had the same shape one level up — killpg
SIGTERM, 5s grace, then a direct-child proc.kill() that orphaned
grandchildren.

Fix: spawn provider children via node:child_process with detached (own
process group) and killProcessGroup(SIGKILL) in the timeout handler —
runShardChild's proven pattern, EPERM/ESRCH fallbacks included. The codex
and gemini copies also gain the reader.cancel() + stderr Promise.race
hardening only the claude copy had (they still carried the blocked-drain
hang it fixed). gstack-detach's watchdog now group-SIGKILLs after the
grace.

Regression net: test/session-runner-groupkill.test.ts drives the REAL
runSkillTest against a fake claude shim (PATH override) that spawns a
grandchild and wedges — the run must classify timeout within budget and
leave neither shim nor grandchild alive — plus source pins on all three
runners (detached + killProcessGroup, no bare timeout kill, no Bun.spawn
reversion).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: skill-e2e-opus-47 renders SKILL.md fixtures into a mkdtemp — never the live tree

mkEvalRoot ran gen-skill-docs with cwd=ROOT, regenerating every in-repo
SKILL.md mid-run while concurrent paid shards copyFileSync those same files
in their beforeAll (EVALS_JOBS>=4 locally, 2 per CI slice) — a sibling
could capture a half-regenerated or opus-rendered SKILL.md, and a timeout
before afterAll stranded the whole tree at the wrong model for every later
shard. A cross-shard race that could flake ANY concurrent paid test.

Render via the --out-dir flag gen-skill-docs grew for exactly this reason
(mirrors the repo layout, which is all the fixture reads), read the skill
heads from the render dir, delete it, and drop the afterAll restore-regen
entirely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: claude CLI version resolves in the runner parent, never on a test thread

Eng-review finding: getClaudeCliVersion's fallback is a SYNCHRONOUS
spawnSync on the same thread that polls concurrent PTY/session tests — the
judgePtyState blocking class this overhaul kills elsewhere. The paid runner
parent now resolves it once (cached) and stamps GSTACK_CLAUDE_CLI_VERSION
into every shard's env; eval-store short-circuits on the env var, and the
fallback spawn's budget tightens 10s -> 3s (bounded one-time stall, records
'unknown' on a slow CLI).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: wire skippedTests end-to-end through runPaidShard

The census unit tests hand-built outcomes and the classifier tests parsed
strings; nothing proved a real child's ' N skip' recap flows into
outcome.skippedTests and the formatSummary label. A commandFor fake now
prints the recap shape and the test asserts the parsed counts, the
all-skipped predicate, and the 'verified nothing' label.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: make the setup composites rerun-safe (codex diff-review hardenings)

restore-deps: 'cp -r SRC node_modules' with an existing node_modules NESTS
the copy and leaves stale deps active — rm first. register-gstack-skills:
'ln -snf' hard-errors under set -eu when a REAL directory occupies the
gstack slot — clear a non-symlink leftover first. CI workspaces are fresh
today; a reusable composite must survive dirty reruns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: sweep — every sync spawn in the test trees carries a timeout (436 sites, 157 files)

spawnSync/execSync/Bun.spawnSync BLOCK the main thread, so bun's in-process
per-test timeout can never fire while one waits — a hung child (stdin read,
network probe, dead daemon) wedges the whole shard until the runner's
external wall-clock SIGKILL. This exact class reached main: free-tests run
33262077256, test/gstack-memory-ingest.test.ts (normally 2.3s) held shard 2
at the 360s wall while its five siblings finished in ~65s.

Mechanical sweep in two waves (12 + 4 fan-out agents, every edit verified
against its call site): default timeout: 30_000 (matches the free runner's
per-test budget), 120_000 for genuinely slow ops (installs, builds,
playwright, provider CLIs), helper wrappers fixed ONCE where call sites
route through them. Sites that only LOOK like calls (string fixtures, grep
needles, comments) were skipped with reasons — the enforcement commit that
follows marks them exempt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: sync-spawn timeout tripwire — the wedge class stays extinct

Free scanner over all test trees (test/, browse/test/, design/test/,
make-pdf/test/, ios-qa, browser-skills): every spawnSync/execSync/
Bun.spawnSync call site must carry a timeout within a 30-line options
window, or an explicit '// tripwire-exempt: <reason>' marker. Comment
lines are skipped; exemptions are counted and ratcheted shrink-only
(ceiling 6 = the 6 string-fixture/grep-needle sites where the pattern is
CONTENT, not a call — marked in this commit). A scan-sanity test pins that
the scanner still sees >100 real call sites so it can never rot to a
vacuous green. Companion to the 436-site sweep in the previous commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: paid-lane flake telemetry — record-level attempts, flaky_retries, report surfacing

bun --retry leaves a retried pass INVISIBLE in its output: a fail-then-pass
prints the error detail but no (fail) result line and recaps as a clean
pass (probed live on 1.3.10). So attempts are recorded where they cannot
lie: EvalCollector.addTest stamps a 1-based attempt on same-name re-records
(a retried test runs its body again and re-records), finalized runs carry
flaky_retries, printSummary warns loudly, and the fail-closed slices report
lists every passed-only-on-retry test — recorded and ranked, never blocking
and never silent. Cross-model confirmed (codex reached the same don't-parse
-the-stream conclusion independently).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: free-lane flake ledger — retry ON in CI, flaky-passes recorded and uploaded

The runner's attribution-gated flaky-retry pass (cap 5, truncation veto)
was OFF in the required lane and its FLAKY-PASS evidence was console-only —
so a single timing flake red the merge gate while repeat offenders stayed
unenumerable. free-tests.yml now sets GSTACK_FREE_RETRY_FLAKY=1 and points
GSTACK_FLAKE_LEDGER at runner.temp; every flaky-pass appends a JSONL entry
(SINGLE writer: the parent runner — no concurrent-append hazard by
construction; fail-open with a loud warning so a broken ledger can never
red the lane) and the artifact uploads UNCONDITIONALLY — a flaky-pass run
is green, which is exactly when the evidence matters. Wiring pinned by
free-tests-workflow-wiring; ledger behavior unit-tested incl. the fail-open
path. Matches 2026 industry practice (retry for data, quarantine out of
merge-blocking but never out of logging) with the repo's own receipts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: eval:flake-rank — the flake-telemetry dial

Aggregates per-test series across every finalized eval-store run (shard
dirs included) plus the free flake ledger: runs, fails, RETRIED PASSES
(the flake signature), avg duration — ranked retries-first. This is the
readable dial behind two policies: a flaky pass never blocks a merge but
is always ranked here, and the WS16 required-check promotion needs weeks
of clean flake-rank, not vibes. --json for machines, --dir for downloaded
CI artifacts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: two-phase session timeout — silent APIs die at the startup grace, named

The single spawn-armed timer charged API queue latency to the work budget:
the recurring '0 turns / $0.00 / x3 attempts' failure with four budget-bump
receipts (180->300s, 240->360s, 300->420s, 90->300s). Split: startup phase
(no NDJSON byte yet) kills EARLY at min(grace, timeout) with the distinct
exitReason 'timeout_startup' — an availability verdict, not transcript
archaeology — and the work phase arms on the first byte for the REMAINING
budget, so total wall never exceeds the timeout (tier envelopes are
margin-free: tests pass timeout: CAPTURE_MS and bun-budget the same tier).
Local grace 90s (observed queue latency 60-90s), CI floor 300s (TODOS-filed;
shared runners queue harder), both pinned by the new grace tests with fake
-claude shims covering the late-first-byte and silent-API paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: census integrity — 17 phantom selection keys deleted, reverse invariant added, gitignored dep patterns replaced, local map forks derived

The merge-blocking gate census counted tests that could not run. Deleted
(critic-verified against both quoted-occurrence and dep-registration
liveness): 7 *-prosons-format keys with no declaring test, ship-plan-
completion/-verification, review-plan-completion, design-shotgun-path/
session/full, autoplan-core (dead ~10 months), e2e-harness-audit (its
namesake is a FREE-suite file), plus 2 dead LLM-judge keys and 2 free-file
keys (budget-regression-pty, global-discover) misplaced in the PAID maps.
Census: 191 -> 174 keys, gate 86 -> 78 honest.

The new reverse invariant in touchfiles.test.ts makes the class structurally
impossible: every key must be quoted in a living paid test file OR
registered to an existing paid test file via its dep list (the constructed-
name binding the 2026-08 self-registration sweep established) — zero
exceptions needed today, with a live-file check on any future exception.

Also: '.agents/skills/**' dep patterns replaced with the generator
(scripts/gen-skill-docs.ts) — .agents/ is gitignored, so those patterns
could NEVER match a git diff and review-template edits silently stopped
selecting codex/gemini tests; the codex/gemini local touchfile maps are now
DERIVED from the canonical map (loud throw if a key vanishes) instead of
hand-forked copies that had already drifted. ios-qa-e2e demoted gate ->
periodic: its gate declaration was never executable in CI (hardware
exclusion only applies at tier=periodic), so every Linux PR planned a
hollow shard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: routing journeys lose their answer key and end at the routing decision

The journey tests exist to catch skill-DESCRIPTION regressions (touchfiles:
*/SKILL.md.tmpl), but the fixture CLAUDE.md shipped an explicit
prompt->skill lookup table — with the answer key in context, a badly
regressed frontmatter description still routed correctly, so the tests
could not fail on the exact class they select for. The fixture now carries
only the generic invoke-skills nudge; the frontmatter carries the routing
load. Also capped all 10 journeys at maxTurns 2 / tools [Skill, Read]:
only the FIRST Skill call is asserted, so 5 turns of Read/Bash/Glob/Grep
was pure spend — roughly halves each journey's cost.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: retire decided A/B experiments; vendor the pre-cut fixture; ban raw-SHA fixtures

Three one-shot decision experiments kept re-running weekly as N=1
stochastic comparisons — flaky by construction with near-zero remaining
information: skill-e2e-auq-repetition-cut-ab (its own header: gate "passed
pre-landing, approved 2026-08-25"), skill-e2e-preamble-script-ab ("demoted
post-Phase-3"), and opus-47's fanout arm-vs-arm (parA >= parB across two
SINGLE stochastic runs — a coin flip). Deleted, with their selection keys;
the SDK overlay-harness stays as the maintained instrument for the next
experiment, and opus-47 keeps its routing-precision cases.

verboseSkill() now reads the VENDORED test/fixtures/auq-pre-cut-...-SKILL.md
instead of `git show ab66193e^:...` — a branch-local ref that dies on
branch prune and already failed on shallow clones. New free tripwire
(test/git-ref-fixture-tripwire.test.ts) bans the raw-SHA fixture class
outright: quoted SHA:path rev-specs and gitRef-style hex defaults in the
test trees fail the suite with the vendor-instead instruction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: demote plan-ceo-review-expansion-energy to periodic

Opus generator + a subjective 2-axis >=4/5 LLM-judge threshold sat in the
MERGE-BLOCKING gate — the exact class its sibling posture tests were
demoted for, with a receipt (a +21-line preamble change once flipped the
score). CLAUDE.md's own tiering rule: Opus model test -> periodic. The
weekly lane keeps the regression signal; merges stop paying a judge-
temperament tax.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: paid shards get per-shard TMPDIR + CHROMIUM_PROFILE isolation and a kill-path cleanup backstop

The free runner treats this isolation as MANDATORY (two concurrent shards
on one Chromium profile kill each other's browser; shared tmp
cross-contaminates) — the paid lane had none of it. Doubly load-bearing
here: a shard that hits its 30-min wall is group-SIGKILLed, so per-test
afterAll cleanup never runs; the rmSync backstop is the only thing keeping
wedged runs from accumulating full git-repo workspaces in the shared
tmpdir forever. This is the DAG prerequisite for raising EVALS_JOBS (next
commit) — more concurrency on shared state amplifies exactly the
shared-tree race class opus-47 exhibited.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: paid-runner defaults 4x4 -> 8x2 — halve the local gate worst case

39 of 75 skill-e2e files hold exactly ONE test, so within-shard
concurrency was dead weight for most shards: 4 jobs x 4 concurrency
yielded only ~4-6 real in-flight sessions and a 13-wave local gate worst
case (~6.5h). 8 jobs x 2 gives ~10-13 in-flight — under the
documented-safe ~15 — and ~7 waves (~3.3h worst case). CI lanes keep
their explicit EVALS_JOBS env (2 per slice; 4 for gate-census); this
changes local defaults. Rollback trigger: sustained 429 storms in the WS1
telemetry across 2 PR cycles. test/eval-detach-timeout-floor.test.ts
recomputed green (the raise LOWERS the worst-case floor).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: SHA-pin every action in the secrets-bearing eval lanes

evals.yml and evals-periodic.yml execute PR-authored code with three
provider API keys in env, yet rode mutable action tags (@v7/@v8/@v2/@v4)
— while quality-gate.yml, osv-scanner.yml, and dependency-review.yml
already model the SHA-pin pattern. All 30 uses sites across both lanes now
pin the exact commit (tag noted in a trailing comment); dependabot's
github-actions ecosystem keeps them fresh via PRs instead of silent tag
moves. Pulled forward from the plan's endgame on the CEO-review + outside-
voice agreement: supply-chain pins on secret lanes go first, not last.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: sweep wave 3 — the execFileSync family gets timeouts (90 sites, 17 files)

The tripwire's regex covered spawnSync/execSync/Bun.spawnSync but not
execFileSync — an entire blocking sync-spawn API family that could
reintroduce the shard-wedge class undetected (ship review army). Same
mechanical recipe as waves 1-2: timeout: 30_000 default, 120_000 for slow
ops, shared wrappers fixed once, string-needle sites skipped with reasons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: review-army + adversarial test hardening

- Tripwire scans execFileSync too (ceiling 8: two more grep-needle string
  exemptions); merge-introduced timeout-less spawnSync in
  question-preference-hook fixed — the tripwire caught a site that landed
  on main AFTER the sweep, on its first day.
- gstack-detach gains TWO watchdog kill regression tests: TERM-immune
  grandchild (the killpg-after-grace escalation) and the leader-dies
  variant (the pgid-at-spawn fix — the case the first test cannot see).
- eval-flake-rank gets its unit suite (final-attempt accounting, artifact
  exclusion, shard recursion, recency bound).
- Groupkill/startup-grace shim markers are per-run unique (pid-suffixed
  sleep durations): sibling Conductor worktrees run free suites with no
  machine lock, and fixed markers let one run pgrep/pkill the other's
  shims — a cross-run flake inside the anti-flake tests.
- flake-ledger test pins the project-scoped local default; stale empty
  section headers in touchfiles-data deleted (they invited entries under
  deliberately retired categories).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: adversarial-review runtime fixes across the telemetry + kill paths

- session-runner: exit-labeling keys off 'exit', not 'close' — an orphan
  holding the pipes could relabel a REAL exit (auth failure) as
  'timeout_startup' availability noise; the kill path still always
  group-kills and cancels the reader (labeling and unblocking are separate
  concerns). Work phase arms on a flag, not firstResponseMs===0 (a same-ms
  first byte left the startup timer live all run). The CI startup grace is
  now a real FLOOR (Math.max), matching its name and pinning test.
- gstack-detach: pgid captured AT SPAWN (== child pid under
  start_new_session) — resolving it after the grace raised ESRCH once the
  leader died on SIGTERM, orphaning TERM-immune grandchildren forever.
- test-free-shards: ledger entries carry branch + git_sha (rev-parse split:
  '--abbrev-ref HEAD HEAD' printed the branch twice and recorded it as the
  sha); local ledger default is per-PROJECT, not the machine-global tmpdir.
- eval-flake-rank: per-LINE ledger parse (one torn JSONL line vanished the
  whole series), 60-day recency bound (transcript-bearing files are MBs),
  shared isFinalizedEvalResultFile predicate (the artifact-taxonomy rule
  lived in three places); eval-store exports the predicate and finalize
  stops computing flakyRetries twice; paid-shards cleanup uses async rm
  (a SIGKILLed shard's git-workspace teardown blocked every sibling's
  stream classification on the parent event loop).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: CI trust-boundary + fail-closed repairs (adversarial findings)

- Token/exec separation restored: slices-report (runs PR-authored code:
  bun install + the reconcile runner) drops to contents:read; the PR
  comment moves to a NEW slices-comment job holding the write token with
  ZERO repo code — no checkout, no bun, only downloaded artifacts + jq/gh.
  $GITHUB_ENV/BASH_ENV persistence is job-scoped, so the split is the
  boundary. The matrix-era report job had this property; the consolidation
  had regressed it. Pinned by the wiring test.
- Reconcile exit captured via PIPESTATUS[0] in BOTH lanes: GitHub's default
  run-step shell has no pipefail, so `$?` after `| tee` was tee's exit —
  the fail-closed gate was silently fail-open. Wiring test pins it.
- PR comment: final-attempt accounting restored the dropped COST
  accumulation (the dial read $0 forever), flaky passes render as the
  warning they are (never as failures), and a malformed tests[] artifact
  skips that file instead of aborting the whole comment under bash -e.
- Remaining mutable action tags pinned (free-tests upload-artifact,
  ci-image checkout/docker trio — the image publisher holds packages:write
  and feeds the secret-bearing lanes). restore-deps fallback installs
  --frozen-lockfile; register-gstack-skills validates skill names before
  its rm -rf.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: bump version and changelog (v1.77.0.0)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: update project documentation for v1.77.0.0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: cross-model doc-review fixes — flake-ledger env knobs, CI retry-on note, stale version comment

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: correct CHANGELOG receipt numbers to measured values

Gate census keys: 78 -> 77 (bun-imported E2E_TIERS count). Sweep receipt:
586 sites/176 files -> 499 sites/146 files, measured by running this
branch's spawnsync-timeout-tripwire against origin/main (exit 1, 499
violations across 146 unique files; green on this branch).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: slices-comment creates the PR comment via REST — the write-token job has no git context

The token/exec split gives slices-comment NO checkout by design, and gh's
pr-comment subcommand resolves the repo FROM git — it died with 'not a git
repository' on PR #2746's first run (the update-existing PATCH path was
already explicit-repo REST and worked). Create now posts through
gh api repos/.../issues/N/comments, and the wiring test pins that no
git-context-requiring comment call can creep back into the job.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: startup-grace probes clear CI for local semantics; new probe pins the floor clamp

The two shim probes pass explicit 2s/4s graces, but in CI the runner clamps
any explicit grace up to the 300s floor (deliberate adversarial-review fix),
so 'silent API killed at the grace' died at the 30s work cap instead of 2s —
a deterministic red on every CI run, green locally. The probes now pin LOCAL
semantics with CI cleared (same save/restore pattern as their PATH shim),
and a fourth probe pins the clamp itself: CI=1 + 2s grace + 6s timeout must
kill at the 6s cap, still in the startup phase — proof an explicit low grace
cannot bypass the floor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 08:55:30 -07:00

1353 lines
61 KiB
TypeScript

/**
* Unit tests for bin/gstack-memory-ingest.ts (Lane A).
*
* Covers the unit-testable internals: parseTranscriptJsonl (Codex + Claude Code +
* truncated last line), buildTranscriptPage / buildArtifactPage shape, repoSlug,
* dateOnly, fileChangedSinceState mtime+sha logic, state file load/save with
* schema_version backup-on-mismatch.
*
* E2E coverage (full --probe / --bulk on real ~/.claude/projects) lives in
* test/skill-e2e-memory-ingest.test.ts (Lane F).
*
* Strategy: we re-import the module under test through bun's runtime and shell
* out to it for end-to-end mode tests; for the pure helpers, we re-import the
* source file via dynamic import.
*/
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, mkdirSync, statSync, chmodSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { spawnSync } from "child_process";
const SCRIPT = join(import.meta.dir, "..", "bin", "gstack-memory-ingest.ts");
// ── Helpers ────────────────────────────────────────────────────────────────
function makeTestHome(): string {
return mkdtempSync(join(tmpdir(), "gstack-memory-ingest-"));
}
function runScript(args: string[], env: Record<string, string> = {}): { stdout: string; stderr: string; exitCode: number } {
const result = spawnSync("bun", [SCRIPT, ...args], {
encoding: "utf-8",
timeout: 30000,
env: { ...process.env, ...env },
});
return {
stdout: result.stdout || "",
stderr: result.stderr || "",
exitCode: result.status ?? 1,
};
}
function writeClaudeCodeSession(home: string, projectName: string, sessionId: string, content: string): string {
const projectsDir = join(home, ".claude", "projects", projectName);
mkdirSync(projectsDir, { recursive: true });
const file = join(projectsDir, `${sessionId}.jsonl`);
writeFileSync(file, content, "utf-8");
return file;
}
function writeCodexSession(home: string, ymd: string, content: string): string {
const [y, m, d] = ymd.split("-");
const dir = join(home, ".codex", "sessions", y, m, d);
mkdirSync(dir, { recursive: true });
const file = join(dir, `rollout-${Date.now()}.jsonl`);
writeFileSync(file, content, "utf-8");
return file;
}
// ── --help and --probe ─────────────────────────────────────────────────────
describe("gstack-memory-ingest CLI", () => {
it("prints usage on --help and exits 0", () => {
const r = runScript(["--help"]);
expect(r.exitCode).toBe(0);
expect(r.stderr).toContain("Usage: gstack-memory-ingest");
expect(r.stderr).toContain("--probe");
expect(r.stderr).toContain("--incremental");
expect(r.stderr).toContain("--bulk");
});
it("rejects unknown arguments with exit 1", () => {
const r = runScript(["--bogus-flag"]);
expect(r.exitCode).toBe(1);
expect(r.stderr).toContain("Unknown argument: --bogus-flag");
});
it("--probe on empty home reports 0 files", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const r = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
expect(r.exitCode).toBe(0);
expect(r.stdout).toContain("Total files in window: 0");
rmSync(home, { recursive: true, force: true });
});
it("--probe finds Claude Code sessions", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const session = `{"type":"user","message":{"role":"user","content":"hello"},"timestamp":"${new Date().toISOString()}","cwd":"/tmp/x"}\n{"type":"assistant","message":{"role":"assistant","content":"hi"},"timestamp":"${new Date().toISOString()}"}\n`;
writeClaudeCodeSession(home, "tmp-x", "abc123", session);
const r = runScript(["--probe", "--include-unattributed"], { HOME: home, GSTACK_HOME: gstackHome });
expect(r.exitCode).toBe(0);
expect(r.stdout).toContain("Total files in window: 1");
expect(r.stdout).toContain("transcript");
rmSync(home, { recursive: true, force: true });
});
it("--probe finds Codex sessions", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const today = new Date();
const ymd = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
const session = `{"type":"session_meta","payload":{"id":"sess-xyz","cwd":"/tmp/x","git":{"repository_url":"https://github.com/foo/bar"}},"timestamp":"${today.toISOString()}"}\n`;
writeCodexSession(home, ymd, session);
const r = runScript(["--probe", "--include-unattributed"], { HOME: home, GSTACK_HOME: gstackHome });
expect(r.exitCode).toBe(0);
expect(r.stdout).toContain("Total files in window: 1");
rmSync(home, { recursive: true, force: true });
});
it("--probe finds gstack artifacts (learnings, eureka, ceo-plan)", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(join(gstackHome, "analytics"), { recursive: true });
mkdirSync(join(gstackHome, "projects", "foo-bar", "ceo-plans"), { recursive: true });
writeFileSync(join(gstackHome, "analytics", "eureka.jsonl"), '{"insight":"lake first"}\n');
writeFileSync(join(gstackHome, "projects", "foo-bar", "learnings.jsonl"), '{"key":"a","insight":"b"}\n');
writeFileSync(join(gstackHome, "projects", "foo-bar", "ceo-plans", "2026-05-01-test.md"), "# Plan\n");
const r = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
expect(r.exitCode).toBe(0);
expect(r.stdout).toContain("Total files in window: 3");
expect(r.stdout).toContain("eureka");
expect(r.stdout).toContain("learning");
expect(r.stdout).toContain("ceo-plan");
rmSync(home, { recursive: true, force: true });
});
it("--sources filter limits the walk to specific types", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(join(gstackHome, "analytics"), { recursive: true });
mkdirSync(join(gstackHome, "projects", "foo", "ceo-plans"), { recursive: true });
writeFileSync(join(gstackHome, "analytics", "eureka.jsonl"), '{"insight":"x"}\n');
writeFileSync(join(gstackHome, "projects", "foo", "learnings.jsonl"), '{"key":"a"}\n');
const r = runScript(["--probe", "--sources", "eureka"], { HOME: home, GSTACK_HOME: gstackHome });
expect(r.exitCode).toBe(0);
expect(r.stdout).toContain("Total files in window: 1");
expect(r.stdout).toContain("eureka");
expect(r.stdout).not.toContain("learning ");
rmSync(home, { recursive: true, force: true });
});
it("--sources rejects empty list with exit 1", () => {
const r = runScript(["--probe", "--sources", "bogus"]);
expect(r.exitCode).toBe(1);
expect(r.stderr).toContain("--sources must include at least one of");
});
});
// ── State file behavior ────────────────────────────────────────────────────
describe("gstack-memory-ingest state file", () => {
it("--incremental on empty home creates state file with schema_version: 1", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const r = runScript(["--incremental", "--quiet"], { HOME: home, GSTACK_HOME: gstackHome });
expect(r.exitCode).toBe(0);
const statePath = join(gstackHome, ".transcript-ingest-state.json");
expect(existsSync(statePath)).toBe(true);
const state = JSON.parse(readFileSync(statePath, "utf-8"));
expect(state.schema_version).toBe(1);
expect(state.last_writer).toBe("gstack-memory-ingest");
rmSync(home, { recursive: true, force: true });
});
it("backs up state file on schema_version mismatch", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const statePath = join(gstackHome, ".transcript-ingest-state.json");
writeFileSync(statePath, JSON.stringify({ schema_version: 999, sessions: {} }), "utf-8");
const r = runScript(["--incremental", "--quiet"], { HOME: home, GSTACK_HOME: gstackHome });
expect(r.exitCode).toBe(0);
expect(existsSync(statePath + ".bak")).toBe(true);
const fresh = JSON.parse(readFileSync(statePath, "utf-8"));
expect(fresh.schema_version).toBe(1);
rmSync(home, { recursive: true, force: true });
});
it("backs up state file on JSON parse error", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const statePath = join(gstackHome, ".transcript-ingest-state.json");
writeFileSync(statePath, "{ this is not valid json", "utf-8");
const r = runScript(["--incremental", "--quiet"], { HOME: home, GSTACK_HOME: gstackHome });
expect(r.exitCode).toBe(0);
expect(existsSync(statePath + ".bak")).toBe(true);
rmSync(home, { recursive: true, force: true });
});
});
// ── Security: cwd in transcript JSONL must not reach a shell ─────────────
describe("gstack-memory-ingest security: untrusted cwd cannot trigger shell substitution", () => {
it("does not invoke /bin/sh when a transcript record contains $() in cwd", () => {
// Transcript JSONL is an untrusted surface — a record's `.cwd` value
// can be set by anyone who can write to ~/.claude/projects (cross-machine
// share, prompt-injection appending to the active session log, etc.).
// resolveGitRemote() must use execFileSync, not execSync with template
// interpolation, or `cwd="$(...)"` triggers command substitution under
// /bin/sh -c on the next ingest run.
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const markerDir = mkdtempSync(join(tmpdir(), "gstack-mi-cwd-marker-"));
const marker = join(markerDir, "PWNED");
// Plain $(...) — what an attacker would write into a transcript record.
// execFileSync passes this verbatim to git as a -C argument; execSync
// (the prior code path) wrapped it in a /bin/sh -c template that ran
// the substitution.
const malicious = "$(touch " + marker + ")";
const record = JSON.stringify({
type: "user",
uuid: "11111111-1111-1111-1111-111111111111",
sessionId: "abc",
cwd: malicious,
timestamp: new Date().toISOString(),
message: { role: "user", content: "hi" },
});
writeClaudeCodeSession(home, "-tmp-target", "abc", record + "\n");
const r = runScript(["--incremental", "--quiet"], {
HOME: home,
GSTACK_HOME: gstackHome,
GSTACK_MEMORY_INGEST_NO_WRITE: "1",
});
expect(r.exitCode).toBe(0);
expect(existsSync(marker)).toBe(false);
rmSync(home, { recursive: true, force: true });
rmSync(markerDir, { recursive: true, force: true });
});
});
// ── Transcript parser via re-import of the source module ───────────────────
describe("internal: parseTranscriptJsonl + buildTranscriptPage shape", () => {
it("parses a Claude Code JSONL session", async () => {
const dir = mkdtempSync(join(tmpdir(), "gstack-mi-parse-"));
const file = join(dir, "abc123.jsonl");
const content =
`{"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`;
writeFileSync(file, content, "utf-8");
// Re-import via dynamic import is tricky because the script auto-runs main().
// We instead test via shell invocation: --probe with this file should find 1 transcript.
const home = makeTestHome();
const projDir = join(home, ".claude", "projects", "tmp-foo");
mkdirSync(projDir, { recursive: true });
writeFileSync(join(projDir, "abc123.jsonl"), content, "utf-8");
const r = runScript(["--probe", "--include-unattributed"], { HOME: home, GSTACK_HOME: join(home, ".gstack") });
expect(r.exitCode).toBe(0);
expect(r.stdout).toContain("Total files in window: 1");
rmSync(dir, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
});
it("treats a truncated last line as partial (does not crash)", () => {
const home = makeTestHome();
const projDir = join(home, ".claude", "projects", "tmp-bar");
mkdirSync(projDir, { recursive: true });
// Truncated last line — JSON parse will fail on it
const content =
`{"type":"user","message":{"role":"user","content":"hi"},"timestamp":"2026-05-01T00:00:00Z","cwd":"/tmp/bar"}\n` +
`{"type":"assistant","message":{"role":"assistant","content":"hello"},"timestamp":"2026-05-01T00:00:01Z"}\n` +
`{"type":"assistant","message":{"role":"assistant","content":"this is truncat`; // no closing brace + no newline
writeFileSync(join(projDir, "trunc.jsonl"), content, "utf-8");
const r = runScript(["--probe", "--include-unattributed"], { HOME: home, GSTACK_HOME: join(home, ".gstack") });
// Should not crash; should report 1 transcript
expect(r.exitCode).toBe(0);
expect(r.stdout).toContain("Total files in window: 1");
rmSync(home, { recursive: true, force: true });
});
});
// ── --limit shortcut for smoke tests ───────────────────────────────────────
describe("gstack-memory-ingest --limit", () => {
it("respects --limit by stopping after N writes (mocked via --probe shortcut)", () => {
// Hermetic home: against the operator's real HOME this walked the whole
// transcript corpus (and, post policy-parity, batch-checked its real
// policy store), making a pure arg-parsing assertion slow and flaky.
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const r = runScript(["--probe", "--limit", "1"], { HOME: home, GSTACK_HOME: gstackHome });
// --limit doesn't apply to probe but argument should parse without error
expect(r.exitCode).toBe(0);
rmSync(home, { recursive: true, force: true });
});
it("rejects --limit 0 with exit 1", () => {
const r = runScript(["--probe", "--limit", "0"]);
expect(r.exitCode).toBe(1);
expect(r.stderr).toContain("--limit requires a positive integer");
});
});
// ── Writer regression: batch-import via `gbrain import <dir>` ─────────────
/**
* Stand up a fake `gbrain` shim on PATH that:
* - advertises `import` in `--help` output (gbrainAvailable() passes)
* - records `import <dir>` 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/)
*
* Architecture being verified (post plan-eng-review + Codex outside-voice):
* - new code uses `gbrain import <stagingDir> --no-embed --json` ONE time,
* not `gbrain put <slug>` 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,
opts: { failingPaths?: string[]; collectNothing?: boolean } = {},
): { 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 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}"
ARGS_LOG="${argsFile}"
STAGING_LIST="${stagingListFile}"
FAILING_LIST="${failingList}"
case "\${1:-}" in
--help|-h)
cat <<EOF
Usage: gbrain <command> [options]
Commands:
import <dir> Import markdown directory (batch, content-addressed)
search <query> Keyword search across pages
ask <question> Hybrid semantic + keyword query
EOF
exit 0
;;
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 "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
# collectNothing: simulate gbrain walking the staging dir and finding
# nothing — the real-world shape when .gitignore hides every staged file
# from collect_files. Crucially this writes NO sync-failures.jsonl entry,
# because there is no per-file failure: gbrain never saw the files.
if [ "${opts.collectNothing ? "1" : "0"}" = "1" ]; then
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|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:-<empty>}" >&2
exit 2
;;
esac
`;
const binPath = join(binDir, "gbrain");
writeFileSync(binPath, script, "utf-8");
chmodSync(binPath, 0o755);
return { binDir, logFile, argsFile, stagingListFile };
}
describe("gstack-memory-ingest writer (gbrain v0.20+ batch `import` interface)", () => {
it("probes the gbrain executable directly instead of shelling through command -v", () => {
const source = readFileSync(SCRIPT, "utf-8");
expect(source).not.toContain('command -v gbrain');
// v1.40.0.0: probe routes through lib/gbrain-exec.ts's execGbrainText helper
// (codex review #4 — centralized gbrain spawn surface). Pre-v1.40 the call
// was a direct `execFileSync("gbrain", ["--help"], ...)` inline.
expect(source).toContain('execGbrainText(["--help"]');
});
it("invokes `gbrain import <dir> --no-embed --json` exactly once with hierarchical staging", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
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.
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(logFile)).toBe(true);
// 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+$/);
// 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/);
// 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);
});
// #2353: buildTranscriptPage stored the RAW resolved remote ("" when
// unresolvable) while the frontmatter wrote the normalized "_unattributed".
// The policy filter fast-paths !p.git_remote, so under --include-unattributed
// a `_unattributed → deny` policy never applied to exactly the pages it
// names. Uses the REAL bin/gstack-gbrain-repo-policy (resolved by the client
// relative to lib/, and seeded here through its own `set` verb) — a fake
// echoing tiers would pass on both sides of the fix.
it("a deny policy keyed _unattributed applies to unattributable transcripts (#2353)", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const { binDir, logFile } = installFakeGbrain(home);
const POLICY = join(import.meta.dir, "..", "bin", "gstack-gbrain-repo-policy");
const seeded = spawnSync("bash", [POLICY, "set", "_unattributed", "deny"], {
encoding: "utf-8",
timeout: 30_000,
env: { ...process.env, HOME: home, GSTACK_HOME: gstackHome },
});
expect(seeded.status).toBe(0);
expect(existsSync(join(gstackHome, "gbrain-repo-policy.json"))).toBe(true);
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 || ""}`,
});
// The only candidate page is policy-denied, so nothing may reach gbrain:
// pre-fix, the "" remote bypassed the filter and gbrain import ran.
expect(r.exitCode).toBe(0);
expect(existsSync(logFile)).toBe(false);
});
// Silent-data-loss regression: gbrain accepts the import call, exits 0, and
// reports imported=0 because collect_files found nothing in the staging dir
// (real-world cause: gstack-artifacts-init writes `.gitignore = "*"` into
// $GSTACK_HOME, and `gbrain import` honours .gitignore, so every file staged
// under $GSTACK_HOME is invisible to it).
//
// No per-file failure is written to sync-failures.jsonl — gbrain never SAW
// the files — so readNewFailures returns empty. Before the reconciliation
// check, that made a total loss indistinguishable from success: every
// prepared file got state-recorded as ingested and the pass reported
// "N written". State then said "done", so no later run ever retried.
it("refuses to advance state when gbrain imports fewer pages than were staged", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const { binDir, logFile } = installFakeGbrain(home, { collectNothing: true });
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 || ""}`,
});
// gbrain WAS called — this is not a "gbrain missing" path.
expect(existsSync(logFile)).toBe(true);
// The pass must not claim success.
expect(r.stderr).toMatch(/\[memory-ingest\] ERR:.*accounted for 0 of 1 staged page/);
expect(r.stderr).toMatch(/Refusing to advance state/);
expect(r.stdout).not.toMatch(/written:\s+1/);
// The critical assertion: state must NOT mark the session ingested, or the
// next run skips it forever and the transcript is lost silently.
const statePath = join(gstackHome, ".transcript-ingest-state.json");
if (existsSync(statePath)) {
const state = JSON.parse(readFileSync(statePath, "utf-8"));
expect(Object.keys(state.sessions || {}).length).toBe(0);
}
});
// Originally landed in v1.32.0.0 (PR #1411) on the per-file `gbrain put`
// path. Postgres rejects 0x00 in UTF-8 text columns. Some Claude Code
// transcripts contain NUL inside user-pasted content or tool output. The
// renderPageBody helper strips them so the staged .md never carries them
// into gbrain. Adapted for the batch architecture: we read the staged file
// contents instead of fake-gbrain stdin.
it("strips NUL bytes from the staged body before gbrain import", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
// Shim that copies staging dir into stagingCopy so we can inspect the
// exact bytes that would have been fed to gbrain.
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 <command>"; echo "Commands:"; echo " import <dir> Import"; exit 0 ;;
import)
DIR="\${2:-}"
cp -R "\$DIR" "${stagingCopy}" 2>/dev/null || true
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);
// Pasted content with embedded NUL bytes in a few shapes:
// - inline mid-token: abc\x00def
// - at start of a line
// - at end of a line
// - back-to-back run
const dirty =
`abc\x00def hello\x00\x00world\nleading\x00line\nline-trailing\x00\nclean line\n`;
const session =
`{"type":"user","message":{"role":"user","content":${JSON.stringify(dirty)}},"timestamp":"2026-05-01T00:00:00Z","cwd":"/tmp/nul-test"}\n` +
`{"type":"assistant","message":{"role":"assistant","content":"ok"},"timestamp":"2026-05-01T00:00:01Z"}\n`;
writeClaudeCodeSession(home, "tmp-nul-test", "nul123", 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);
const findMd = spawnSync("find", [stagingCopy, "-name", "*.md", "-type", "f"], {
encoding: "utf-8",
timeout: 30_000,
});
const mdPaths = (findMd.stdout || "").trim().split("\n").filter(Boolean);
expect(mdPaths.length).toBeGreaterThan(0);
const body = readFileSync(mdPaths[0], "utf-8");
// The body that gbrain will read MUST NOT contain any 0x00 byte.
expect(body.includes("\x00")).toBe(false);
// But the surrounding content should survive intact — we strip NUL only.
expect(body).toContain("abcdef");
expect(body).toContain("helloworld");
expect(body).toContain("leadingline");
expect(body).toContain("line-trailing");
expect(body).toContain("clean line");
rmSync(home, { recursive: true, force: true });
});
it("injects title/type/tags into the staged page's YAML frontmatter", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
// 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 <command>"; echo "Commands:"; echo " import <dir> 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",
timeout: 30_000,
});
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 <dir> 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 <slug> Write a page (legacy)"; exit 0 ;;
*) echo "Unknown command: \$1" >&2; 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/bar"}\n`;
writeClaudeCodeSession(home, "tmp-bar", "def456", session);
const r = runScript(["--bulk", "--include-unattributed"], {
HOME: home,
GSTACK_HOME: gstackHome,
PATH: `${binDir}:${process.env.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 <path> --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 });
});
});
// #2105: current Codex rollout records are
// { type: 'response_item', payload: { type: 'message', role, content: [...] } }
// — the legacy payload.message branch never fired on them, so every Codex
// session imported as an empty shell (message_count: 0, 243/243 on the
// reporting machine).
describe("#2105 codex response_item rollout shape", () => {
it("extracts messages from response_item records", async () => {
const { parseTranscriptJsonl } = await import("../bin/gstack-memory-ingest");
const dir = mkdtempSync(join(tmpdir(), "ingest-2105-"));
const file = join(dir, "rollout-2026-06-01.jsonl");
writeFileSync(file, [
JSON.stringify({ type: "session_meta", payload: { id: "s1", cwd: "/tmp/x" }, timestamp: "2026-06-01T00:00:00Z" }),
JSON.stringify({ type: "response_item", payload: { type: "message", role: "user", content: [{ type: "input_text", text: "hello codex" }] } }),
JSON.stringify({ type: "response_item", payload: { type: "message", role: "assistant", content: [{ type: "output_text", text: "hello human" }] } }),
// Non-message response_items must not count as messages.
JSON.stringify({ type: "response_item", payload: { type: "reasoning", summary: [] } }),
].join("\n") + "\n");
const parsed = parseTranscriptJsonl(file)!;
expect(parsed).not.toBeNull();
expect(parsed.agent).toBe("codex");
expect(parsed.message_count).toBe(2);
expect(parsed.body).toContain("## User\n\nhello codex");
expect(parsed.body).toContain("## Assistant\n\nhello human");
rmSync(dir, { recursive: true, force: true });
});
it("legacy payload.message shape still parses", async () => {
const { parseTranscriptJsonl } = await import("../bin/gstack-memory-ingest");
const dir = mkdtempSync(join(tmpdir(), "ingest-2105-legacy-"));
const file = join(dir, "rollout-legacy.jsonl");
writeFileSync(file, [
JSON.stringify({ type: "session_meta", payload: { id: "s2", cwd: "/tmp/y" }, timestamp: "2026-06-01T00:00:00Z" }),
JSON.stringify({ payload: { message: { role: "user", content: "old shape" } } }),
].join("\n") + "\n");
const parsed = parseTranscriptJsonl(file)!;
expect(parsed.message_count).toBe(1);
expect(parsed.body).toContain("## User\n\nold shape");
rmSync(dir, { recursive: true, force: true });
});
});
// ── #2394: --probe counts post-attribution, matching what --bulk would write ─
describe("#2394: probe applies the same attribution gate as prepare", () => {
function makeAttributableCwd(home: string): string {
const repo = join(home, "work", "attributable-repo");
mkdirSync(repo, { recursive: true });
spawnSync("git", ["-C", repo, "init", "-q"], { encoding: "utf-8", timeout: 30_000 });
spawnSync("git", ["-C", repo, "remote", "add", "origin", "https://github.com/foo/bar.git"], { encoding: "utf-8", timeout: 30_000 });
return repo;
}
function writeMixedCorpus(home: string): void {
const attributableCwd = makeAttributableCwd(home);
const ts = new Date().toISOString();
writeClaudeCodeSession(
home, "work-attributable", "attr1",
`{"type":"user","message":{"role":"user","content":"hello"},"timestamp":"${ts}","cwd":"${attributableCwd.replace(/\\/g, "\\\\")}"}\n`,
);
writeClaudeCodeSession(
home, "tmp-nowhere", "unattr1",
`{"type":"user","message":{"role":"user","content":"hello"},"timestamp":"${ts}","cwd":"${join(home, "not-a-repo").replace(/\\/g, "\\\\")}"}\n`,
);
mkdirSync(join(home, "not-a-repo"), { recursive: true });
}
it("probe reports post-attribution counts and names what it skipped", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
writeMixedCorpus(home);
const r = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
expect(r.exitCode).toBe(0);
// Post-attribution: only the transcript whose cwd resolves to a remote.
expect(r.stdout).toContain("Total files in window: 1");
// The excluded remainder is visible, never silent.
expect(r.stdout).toContain("Skipped (unattributed): 1");
rmSync(home, { recursive: true, force: true });
});
it("--include-unattributed restores raw counts", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
writeMixedCorpus(home);
const r = runScript(["--probe", "--include-unattributed"], { HOME: home, GSTACK_HOME: gstackHome });
expect(r.exitCode).toBe(0);
expect(r.stdout).toContain("Total files in window: 2");
rmSync(home, { recursive: true, force: true });
});
it("parity: probe post-attribution count equals what prepare actually processes", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
writeMixedCorpus(home);
const probe = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
expect(probe.exitCode).toBe(0);
const probeNew = Number((probe.stdout.match(/New \(never ingested\):\s+(\d+)/) || [])[1]);
expect(probeNew).toBe(1);
// Same stage on the ingest side: the transcripts that reach the import
// step (written + failed) are exactly the ones that passed the shared
// attribution gate in preparePages. No gbrain is configured in this
// hermetic env, so the attributable transcript FAILS at import — that is
// fine: parity is a prepare-stage invariant (probe post-attribution ==
// prepare post-attribution), deliberately NOT == final written (#2394).
const inc = runScript(["--incremental", "--quiet"], { HOME: home, GSTACK_HOME: gstackHome });
const m = inc.stderr.match(/(\d+) written, (\d+) failed/) || inc.stdout.match(/(\d+) written, (\d+) failed/);
expect(m).not.toBeNull();
const reachedImport = Number(m![1]) + Number(m![2]);
expect(reachedImport).toBe(probeNew);
rmSync(home, { recursive: true, force: true });
});
it("a multi-MB transcript is still classified correctly (bounded probe read)", () => {
// The probe reads a BOUNDED 256KB prefix, never the whole file (plan C7).
// The cwd sits on the first line; >1MB of filler follows. Classification
// must come out attributable — and stay cheap on real multi-MB corpora.
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const attributableCwd = join(home, "work", "attributable-repo");
mkdirSync(attributableCwd, { recursive: true });
spawnSync("git", ["-C", attributableCwd, "init", "-q"], { encoding: "utf-8", timeout: 30_000 });
spawnSync("git", ["-C", attributableCwd, "remote", "add", "origin", "https://github.com/foo/bar.git"], { encoding: "utf-8", timeout: 30_000 });
const ts = new Date().toISOString();
const cwdLine = `{"type":"user","message":{"role":"user","content":"hello"},"timestamp":"${ts}","cwd":"${attributableCwd.replace(/\\/g, "\\\\")}"}\n`;
const filler = `{"type":"assistant","message":{"role":"assistant","content":"${"x".repeat(1000)}"}}\n`;
const body = cwdLine + filler.repeat(1100); // > 1MB after the cwd line
expect(body.length).toBeGreaterThan(1024 * 1024);
writeClaudeCodeSession(home, "work-attributable", "big1", body);
const r = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
expect(r.exitCode).toBe(0);
expect(r.stdout).toContain("Total files in window: 1");
expect(r.stdout).not.toContain("Skipped (unattributed)");
rmSync(home, { recursive: true, force: true });
});
it("Codex format: session_meta cwd attributes the transcript in the probe", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const attributableCwd = makeAttributableCwd(home);
const today = new Date();
const ymd = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
const session = `{"type":"session_meta","payload":{"id":"sess-meta-cwd","cwd":"${attributableCwd.replace(/\\/g, "\\\\")}"},"timestamp":"${today.toISOString()}"}\n`;
writeCodexSession(home, ymd, session);
const r = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
expect(r.exitCode).toBe(0);
expect(r.stdout).toContain("Total files in window: 1");
expect(r.stdout).not.toContain("Skipped (unattributed)");
rmSync(home, { recursive: true, force: true });
});
it("parity: a Codex cwd appearing only on a LATER record is unattributed in probe AND prepare", () => {
// parseTranscriptJsonl reads Codex cwd from the session_meta FIRST record
// ONLY. The probe mirrors those exact rules — the pre-fix probe scanned
// every line for any cwd and DIVERGED on this shape (probe said
// attributable, prepare said not).
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const attributableCwd = makeAttributableCwd(home);
const today = new Date();
const ymd = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
const session =
`{"type":"session_meta","payload":{"id":"sess-late-cwd"},"timestamp":"${today.toISOString()}"}\n` +
`{"type":"response_item","payload":{"type":"message","role":"user","content":[{"text":"hi"}]},"cwd":"${attributableCwd.replace(/\\/g, "\\\\")}"}\n`;
writeCodexSession(home, ymd, session);
const probe = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
expect(probe.exitCode).toBe(0);
expect(probe.stdout).toContain("Total files in window: 0");
expect(probe.stdout).toContain("Skipped (unattributed): 1");
// Prepare agrees: nothing reaches the import stage (written + failed = 0)
// and the skip is attributed to the same gate.
const inc = runScript(["--incremental"], { HOME: home, GSTACK_HOME: gstackHome });
expect(inc.exitCode).toBe(0);
const written = Number((inc.stdout.match(/written:\s+(\d+)/) || [])[1]);
const failed = Number((inc.stdout.match(/failed:\s+(\d+)/) || [])[1]);
const unattrib = Number((inc.stdout.match(/skipped \(unattrib\):\s+(\d+)/) || [])[1]);
expect(written + failed).toBe(0);
expect(unattrib).toBe(1);
rmSync(home, { recursive: true, force: true });
});
});
// ── #2392: transcript ingest honors the per-remote trust policy ─────────────
//
// The same store the code-import gate honors (bin/gstack-gbrain-sync.ts):
// tier `deny` and `read-only` transcripts are skipped with their own counters;
// a store that EXISTS but can't be read is a hard error before any writes
// (never a silent bypass of a set policy); no store at all = zero policy work.
// The policy store is seeded through the REAL bin/gstack-gbrain-repo-policy
// script (its `set` verb owns the file schema + URL normalization).
describe("#2392: transcript ingest honors per-remote trust policy", () => {
const POLICY_BIN = join(import.meta.dir, "..", "bin", "gstack-gbrain-repo-policy");
/** Attributable temp git repo whose origin points at `remoteUrl`. */
function makeRepoWithRemote(home: string, name: string, remoteUrl: string): string {
const repo = join(home, "work", name);
mkdirSync(repo, { recursive: true });
spawnSync("git", ["-C", repo, "init", "-q"], { encoding: "utf-8", timeout: 30_000 });
spawnSync("git", ["-C", repo, "remote", "add", "origin", remoteUrl], { encoding: "utf-8", timeout: 30_000 });
return repo;
}
function writeSessionForRepo(home: string, projectName: string, sessionId: string, cwd: string): void {
const record = JSON.stringify({
type: "user",
message: { role: "user", content: `hello from ${sessionId}` },
timestamp: new Date().toISOString(),
cwd,
});
writeClaudeCodeSession(home, projectName, sessionId, record + "\n");
}
function setPolicy(gstackHome: string, url: string, tier: string): void {
const r = spawnSync(POLICY_BIN, ["set", url, tier], {
encoding: "utf-8",
timeout: 30_000,
env: { ...process.env, GSTACK_HOME: gstackHome },
});
expect(r.status).toBe(0);
}
function stateSessions(gstackHome: string): string[] {
const statePath = join(gstackHome, ".transcript-ingest-state.json");
if (!existsSync(statePath)) return [];
return Object.keys(JSON.parse(readFileSync(statePath, "utf-8")).sessions || {});
}
it("(a) deny remote's transcript is skipped and counted as skipped_policy_deny", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const { binDir, logFile } = installFakeGbrain(home);
const denyCwd = makeRepoWithRemote(home, "denied", "https://github.com/denyme/denied.git");
const okCwd = makeRepoWithRemote(home, "allowed", "https://github.com/okorg/okrepo.git");
writeSessionForRepo(home, "work-denied", "denysess1", denyCwd);
writeSessionForRepo(home, "work-allowed", "oksess1", okCwd);
setPolicy(gstackHome, "https://github.com/denyme/denied.git", "deny");
const r = runScript(["--bulk", "--quiet"], {
HOME: home,
GSTACK_HOME: gstackHome,
PATH: `${binDir}:${process.env.PATH || ""}`,
});
expect(r.exitCode).toBe(0);
expect(r.stdout).toMatch(/written:\s+1/);
expect(r.stdout).toMatch(/skipped \(policy deny\):\s+1/);
expect(r.stdout).not.toMatch(/skipped \(policy read-only\)/);
// Only the allowed session was imported + state-recorded.
expect(existsSync(logFile)).toBe(true);
const sessions = stateSessions(gstackHome);
expect(sessions.length).toBe(1);
expect(sessions[0]).toContain("oksess1");
rmSync(home, { recursive: true, force: true });
});
it("(b) read-only remote's transcript is skipped and counted as skipped_policy_readonly", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const { binDir } = installFakeGbrain(home);
const roCwd = makeRepoWithRemote(home, "readonly", "https://github.com/roorg/rorepo.git");
const okCwd = makeRepoWithRemote(home, "allowed", "https://github.com/okorg/okrepo.git");
writeSessionForRepo(home, "work-readonly", "rosess1", roCwd);
writeSessionForRepo(home, "work-allowed", "oksess1", okCwd);
setPolicy(gstackHome, "https://github.com/roorg/rorepo.git", "read-only");
const r = runScript(["--bulk", "--quiet"], {
HOME: home,
GSTACK_HOME: gstackHome,
PATH: `${binDir}:${process.env.PATH || ""}`,
});
expect(r.exitCode).toBe(0);
expect(r.stdout).toMatch(/written:\s+1/);
expect(r.stdout).toMatch(/skipped \(policy read-only\):\s+1/);
const sessions = stateSessions(gstackHome);
expect(sessions.length).toBe(1);
expect(sessions[0]).toContain("oksess1");
rmSync(home, { recursive: true, force: true });
});
it("(c) read-write remote's transcript is ingested (reaches gbrain import)", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const { binDir, logFile } = installFakeGbrain(home);
const rwCwd = makeRepoWithRemote(home, "readwrite", "https://github.com/rworg/rwrepo.git");
writeSessionForRepo(home, "work-readwrite", "rwsess1", rwCwd);
setPolicy(gstackHome, "https://github.com/rworg/rwrepo.git", "read-write");
const r = runScript(["--bulk", "--quiet"], {
HOME: home,
GSTACK_HOME: gstackHome,
PATH: `${binDir}:${process.env.PATH || ""}`,
});
expect(r.exitCode).toBe(0);
expect(r.stdout).toMatch(/written:\s+1/);
expect(r.stdout).not.toMatch(/skipped \(policy/);
// gbrain import ran exactly once — the page reached the import stage.
const calls = readFileSync(logFile, "utf-8").trim().split("\n").filter(Boolean);
expect(calls.length).toBe(1);
expect(stateSessions(gstackHome).length).toBe(1);
rmSync(home, { recursive: true, force: true });
});
it("(d) corrupted store: hard error before any writes, message names recovery", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const { binDir, logFile } = installFakeGbrain(home);
const cwd = makeRepoWithRemote(home, "somerepo", "https://github.com/some/repo.git");
writeSessionForRepo(home, "work-somerepo", "somesess1", cwd);
// Corrupt store — the batch verb refuses (exit 2), the client classifies
// `unreadable`, and ingest must abort rather than bypass a set policy.
writeFileSync(join(gstackHome, "gbrain-repo-policy.json"), "not valid json{", "utf-8");
const r = runScript(["--bulk", "--quiet"], {
HOME: home,
GSTACK_HOME: gstackHome,
PATH: `${binDir}:${process.env.PATH || ""}`,
});
expect(r.exitCode).toBe(1);
expect(r.stderr).toMatch(/\[memory-ingest\] ERR:.*repo policy store exists/);
expect(r.stderr).toContain("gstack-gbrain-repo-policy list");
expect(r.stderr).toContain("/setup-gbrain");
// Nothing written: no gbrain import call, no state file, store untouched.
expect(existsSync(logFile)).toBe(false);
expect(stateSessions(gstackHome).length).toBe(0);
expect(readFileSync(join(gstackHome, "gbrain-repo-policy.json"), "utf-8")).toBe("not valid json{");
rmSync(home, { recursive: true, force: true });
});
it("(e) no store at all: no policy filtering, transcript ingests normally", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const { binDir } = installFakeGbrain(home);
const cwd = makeRepoWithRemote(home, "freerepo", "https://github.com/free/repo.git");
writeSessionForRepo(home, "work-freerepo", "freesess1", cwd);
const r = runScript(["--bulk", "--quiet"], {
HOME: home,
GSTACK_HOME: gstackHome,
PATH: `${binDir}:${process.env.PATH || ""}`,
});
expect(r.exitCode).toBe(0);
expect(r.stdout).toMatch(/written:\s+1/);
expect(r.stdout).not.toMatch(/skipped \(policy/);
expect(stateSessions(gstackHome).length).toBe(1);
rmSync(home, { recursive: true, force: true });
});
it("(f) probe policy parity: a denied remote's transcript lands in skipped_policy_deny, not new_count", () => {
// --probe used to count policy-denied transcripts as ingestible (it only
// applied attribution), so its numbers overstated what --bulk would write.
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const denyCwd = makeRepoWithRemote(home, "denied", "https://github.com/denyme/denied.git");
writeSessionForRepo(home, "work-denied", "denysess1", denyCwd);
setPolicy(gstackHome, "https://github.com/denyme/denied.git", "deny");
const r = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
expect(r.exitCode).toBe(0);
expect(r.stdout).toContain("Total files in window: 0");
expect(r.stdout).toMatch(/New \(never ingested\):\s+0/);
expect(r.stdout).toMatch(/Skipped \(policy deny\):\s+1/);
expect(r.stdout).not.toMatch(/Skipped \(policy read-only\)/);
rmSync(home, { recursive: true, force: true });
});
it("(g) probe policy parity: a read-only remote's transcript lands in skipped_policy_readonly", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const roCwd = makeRepoWithRemote(home, "readonly", "https://github.com/roorg/rorepo.git");
writeSessionForRepo(home, "work-readonly", "rosess1", roCwd);
setPolicy(gstackHome, "https://github.com/roorg/rorepo.git", "read-only");
const r = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
expect(r.exitCode).toBe(0);
expect(r.stdout).toContain("Total files in window: 0");
expect(r.stdout).toMatch(/Skipped \(policy read-only\):\s+1/);
rmSync(home, { recursive: true, force: true });
});
it("(h) --limit counts policy-PERMITTED pages only: a denied-first corpus still writes the allowed page", () => {
// Walk order is deterministic here: Claude Code projects are walked
// BEFORE Codex sessions (walkAllSources), so the DENIED transcript is
// prepared first. Pre-fix, --limit 1 was applied to the unfiltered
// prepared array — the denied record consumed the limit and the permitted
// one starved (written: 0).
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const { binDir } = installFakeGbrain(home);
const denyCwd = makeRepoWithRemote(home, "denied", "https://github.com/denyme/denied.git");
writeSessionForRepo(home, "work-denied", "denysess1", denyCwd); // Claude Code: walked first
const okCwd = makeRepoWithRemote(home, "allowed", "https://github.com/okorg/okrepo.git");
const today = new Date();
const ymd = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
writeCodexSession(
home, ymd,
`{"type":"session_meta","payload":{"id":"oksess-codex","cwd":"${okCwd.replace(/\\/g, "\\\\")}"},"timestamp":"${today.toISOString()}"}\n`,
);
setPolicy(gstackHome, "https://github.com/denyme/denied.git", "deny");
const r = runScript(["--bulk", "--quiet", "--limit", "1"], {
HOME: home,
GSTACK_HOME: gstackHome,
PATH: `${binDir}:${process.env.PATH || ""}`,
});
expect(r.exitCode).toBe(0);
expect(r.stdout).toMatch(/written:\s+1/);
expect(r.stdout).toMatch(/skipped \(policy deny\):\s+1/);
// The page that landed is the PERMITTED one (the Codex session), not
// whichever record happened to be walked first.
const sessions = stateSessions(gstackHome);
expect(sessions.length).toBe(1);
expect(sessions[0]).toContain("rollout-");
rmSync(home, { recursive: true, force: true });
});
it("artifacts are never policy-filtered, even when their project's remote is denied", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(join(gstackHome, "projects", "denyme-denied"), { recursive: true });
const { binDir } = installFakeGbrain(home);
// A learning artifact under a project slug matching a denied remote —
// the policy is keyed by git remote, which artifacts don't have.
writeFileSync(join(gstackHome, "projects", "denyme-denied", "learnings.jsonl"), '{"key":"a","insight":"b"}\n');
setPolicy(gstackHome, "https://github.com/denyme/denied.git", "deny");
const r = runScript(["--bulk", "--quiet"], {
HOME: home,
GSTACK_HOME: gstackHome,
PATH: `${binDir}:${process.env.PATH || ""}`,
});
expect(r.exitCode).toBe(0);
expect(r.stdout).toMatch(/written:\s+1/);
expect(r.stdout).not.toMatch(/skipped \(policy/);
rmSync(home, { recursive: true, force: true });
});
});