From dfb7330a8c7febea9c7e5ac4eaa6528343b49958 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 3 Oct 2026 20:44:55 +0000 Subject: [PATCH] W3 T3/T4/E2: design-doc discovery runs bin/gstack-design-doc-find on the state root; drop DESIGN_DOC_HELD rows --- autoplan/SKILL.md.tmpl | 2 +- bin/gstack-design-doc-find | 50 ++++++++ scripts/resolvers/design-doc-discovery.ts | 45 ++----- ...toplan-preconfigured-onboarding-ar.test.ts | 2 +- test/ceo-finding-fixture.test.ts | 3 +- test/gen-skill-docs.test.ts | 4 + test/gstack-design-doc-find.test.ts | 111 ++++++++++++++++++ test/helpers/touchfiles-data.ts | 4 +- test/instruction-facts.test.ts | 5 - 9 files changed, 183 insertions(+), 43 deletions(-) create mode 100755 bin/gstack-design-doc-find create mode 100644 test/gstack-design-doc-find.test.ts diff --git a/autoplan/SKILL.md.tmpl b/autoplan/SKILL.md.tmpl index 640530aca..d3e9015c0 100644 --- a/autoplan/SKILL.md.tmpl +++ b/autoplan/SKILL.md.tmpl @@ -224,7 +224,7 @@ Re-run: copy RESTORE_PATH's bytes to SOURCE_PLAN, then /autoplan. ### Step 2: Read context - Read CLAUDE.md, TODOS.md, git log -30, git diff against the base branch --stat -- Discover design docs: `ls -t ~/.gstack/projects/$SLUG/*-design-*.md 2>/dev/null | head -1` +- Discover design docs: `~/.claude/skills/gstack/bin/gstack-design-doc-find "$SLUG" "$BRANCH"` (prints the doc path, or nothing) - Detect UI scope: grep the plan for view/rendering terms (component, screen, form, button, modal, layout, dashboard, sidebar, nav, dialog). Require 2+ matches. Exclude false positives ("page" alone, "UI" in acronyms). diff --git a/bin/gstack-design-doc-find b/bin/gstack-design-doc-find new file mode 100755 index 000000000..a48462c4a --- /dev/null +++ b/bin/gstack-design-doc-find @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# gstack-design-doc-find — print the design doc a plan review should read. +# +# Usage: gstack-design-doc-find +# +# Prints one absolute path, or nothing when no design doc exists. Exit 0 either +# way; exit 2 on a usage error. +# +# Precedence: +# 1. newest $GSTACK_STATE_ROOT/projects//*--design-*.md +# 2. else newest $GSTACK_STATE_ROOT/projects//*-design-*.md +# 3. a repo doc ($TOP/DESIGN.md, else newest $TOP/docs/designs/*.md) wins when +# no state-root doc exists or it is newer than the state-root doc. +# office-hours writes both copies; the committed copy is what teammates +# see, but a stale repo doc never shadows a newer private session. +set -u + +if [ "$#" -ne 2 ] || [ -z "$1" ]; then + echo "usage: gstack-design-doc-find " >&2 + exit 2 +fi +SLUG=$1 +BRANCH=$2 + +eval "$("$(dirname "$0")/gstack-paths")"; : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}" + +newest() { + ls -t "$@" 2>/dev/null | head -1 +} + +PROJECT_DIR="$GSTACK_STATE_ROOT/projects/$SLUG" +LOCALDOC=$(newest "$PROJECT_DIR"/*-"$BRANCH"-design-*.md) +[ -z "$LOCALDOC" ] && LOCALDOC=$(newest "$PROJECT_DIR"/*-design-*.md) + +REPODOC="" +TOP=$(git rev-parse --show-toplevel 2>/dev/null || true) +if [ -n "$TOP" ]; then + if [ -f "$TOP/DESIGN.md" ]; then + REPODOC="$TOP/DESIGN.md" + else + REPODOC=$(newest "$TOP"/docs/designs/*.md) + fi +fi + +DESIGN="$LOCALDOC" +if [ -n "$REPODOC" ] && { [ -z "$LOCALDOC" ] || [ "$REPODOC" -nt "$LOCALDOC" ]; }; then + DESIGN="$REPODOC" +fi +[ -n "$DESIGN" ] && printf '%s\n' "$DESIGN" +exit 0 diff --git a/scripts/resolvers/design-doc-discovery.ts b/scripts/resolvers/design-doc-discovery.ts index 757d85efe..d3aab7328 100644 --- a/scripts/resolvers/design-doc-discovery.ts +++ b/scripts/resolvers/design-doc-discovery.ts @@ -1,47 +1,26 @@ /** - * {{DESIGN_DOC_DISCOVERY}} — the canonical design-doc discovery block (#703). + * {{DESIGN_DOC_DISCOVERY}} — the canonical design-doc discovery block. * - * Finds the design doc a plan review should read: newest branch-scoped doc - * under ~/.gstack/projects//, falling back to newest project-scoped - * doc, then lets a repo-local doc (DESIGN.md or docs/designs/*.md) win when - * it is at least as fresh. office-hours dual-writes docs/designs/ alongside - * ~/.gstack, and the committed copy is what teammates see — but a stale old - * repo doc must never shadow a newer private session. - * - * Single source of truth for the freshness-preference logic that previously - * lived verbatim in plan-ceo-review, plan-eng-review, plan-devex-review, and - * the prerequisite-skill re-check in review.ts (GStack 2 fork-port wave, - * time-attack/gstack). Drift between copies meant plan reviews could - * disagree about which design doc wins. + * One invocation of bin/gstack-design-doc-find, which owns the precedence + * (newest branch-scoped doc in the state root's project directory, then the + * newest project-scoped doc, then a repo-local DESIGN.md or docs/designs/*.md + * when it is at least as fresh) and resolves the state root through + * bin/gstack-paths. Plan reviews, autoplan and the prerequisite-skill re-check + * in spec-review.ts all render this one block, so they agree on which doc wins. * * The fragment carries no code fences — the {{DESIGN_DOC_DISCOVERY}} token * sits inside each caller's ```bash block. Callers must set $SLUG and - * $BRANCH first (and `setopt +o nomatch` for zsh); the block sets $DESIGN - * and prints "Design doc found: ..." or "No design doc found". + * $BRANCH first; the block sets $DESIGN and prints "Design doc found: ..." or + * "No design doc found". */ import type { TemplateContext } from './types'; /** - * Raw canonical fragment, exported so TS resolvers (review.ts's prerequisite - * re-check) can interpolate it into their own template strings instead of - * embedding a drifting copy. + * Raw canonical fragment, exported so TS resolvers (spec-review.ts's + * prerequisite re-check) can interpolate it into their own template strings. */ -export const DESIGN_DOC_DISCOVERY_BLOCK = `_LOCALDOC=$(ls -t ~/.gstack/projects/$SLUG/*-$BRANCH-design-*.md 2>/dev/null | head -1) -[ -z "$_LOCALDOC" ] && _LOCALDOC=$(ls -t ~/.gstack/projects/$SLUG/*-design-*.md 2>/dev/null | head -1) -# Repo-local docs win when at least as fresh (#703): office-hours dual-writes -# docs/designs/ alongside ~/.gstack, and the committed copy is what teammates -# see. A stale old repo doc never shadows a newer private session. -_REPOTOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") -_REPODOC="" -if [ -n "$_REPOTOP" ]; then - [ -f "$_REPOTOP/DESIGN.md" ] && _REPODOC="$_REPOTOP/DESIGN.md" - [ -z "$_REPODOC" ] && _REPODOC=$(ls -t "$_REPOTOP"/docs/designs/*.md 2>/dev/null | head -1) -fi -DESIGN="$_LOCALDOC" -if [ -n "$_REPODOC" ] && { [ -z "$_LOCALDOC" ] || [ "$_REPODOC" -nt "$_LOCALDOC" ]; }; then - DESIGN="$_REPODOC" -fi +export const DESIGN_DOC_DISCOVERY_BLOCK = `DESIGN=$(~/.claude/skills/gstack/bin/gstack-design-doc-find "$SLUG" "$BRANCH") [ -n "$DESIGN" ] && echo "Design doc found: $DESIGN" || echo "No design doc found"`; export function generateDesignDocDiscovery(_ctx: TemplateContext): string { diff --git a/test/autoplan-preconfigured-onboarding-ar.test.ts b/test/autoplan-preconfigured-onboarding-ar.test.ts index aaec27ffb..b5777f7cf 100644 --- a/test/autoplan-preconfigured-onboarding-ar.test.ts +++ b/test/autoplan-preconfigured-onboarding-ar.test.ts @@ -23,7 +23,7 @@ function fixture(plan = original) { const env = { PATH: process.env.PATH!, HOME: home, GSTACK_HOME: state, GSTACK_STATE_ROOT: state }; const start = () => execFileSync(join(root, 'bin/gstack-skill-start'), ['--skill', 'autoplan'], { cwd, env, encoding: 'utf8', timeout: 30_000 }); - const discover = () => execFileSync('bash', ['-c', DESIGN_DOC_DISCOVERY_BLOCK], + const discover = () => execFileSync('bash', ['-c', DESIGN_DOC_DISCOVERY_BLOCK.replaceAll('~/.claude/skills/gstack/bin/', `${root}/bin/`)], { cwd, env: { ...env, SLUG: 'chain-fixture', BRANCH: 'main' }, encoding: 'utf8', timeout: 5000 }).trim(); return { cwd, home, state, planFile, start, discover, cleanup: () => rmSync(temp, { recursive: true, force: true }) }; } diff --git a/test/ceo-finding-fixture.test.ts b/test/ceo-finding-fixture.test.ts index cc6fc8832..38790075a 100644 --- a/test/ceo-finding-fixture.test.ts +++ b/test/ceo-finding-fixture.test.ts @@ -301,7 +301,8 @@ describe('CEO finding fixture establishes scope before launch', () => { const plan = '# Export saved settings\nReview the CSV formatter before implementation.\n'; const design = '# Settings export design\n\n## Problem\nOperators need saved settings in a spreadsheet for offline comparison.\n\n## Approach\nReuse the settings API and escape commas, quotes, and newlines in a CSV formatter.\n'; seedCeoFindingProject(cwd, plan, design); - const output = execFileSync('bash', ['-c', `SLUG=fixture; BRANCH=main; ${DESIGN_DOC_DISCOVERY_BLOCK}`], { + const block = DESIGN_DOC_DISCOVERY_BLOCK.replaceAll('~/.claude/skills/gstack/bin/', `${path.resolve(import.meta.dir, '..')}/bin/`); + const output = execFileSync('bash', ['-c', `SLUG=fixture; BRANCH=main; ${block}`], { cwd, env: { PATH: process.env.PATH!, HOME: home }, encoding: 'utf8', timeout: 10_000, }); expect(output).toBe(`Design doc found: ${path.join(cwd, 'DESIGN.md')}\n`); diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index 8a626db9e..9bf60e4b4 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -1927,6 +1927,10 @@ describe('BENEFITS_FROM resolver', () => { fs.chmodSync(helper, 0o755); fs.copyFileSync(path.join(ROOT, 'bin/gstack-state-root.sh'), path.join(path.dirname(helper), 'gstack-state-root.sh')); fs.copyFileSync(path.join(ROOT, 'bin/gstack-remote-identity.sh'), path.join(path.dirname(helper), 'gstack-remote-identity.sh')); + for (const bin of ['gstack-paths', 'gstack-design-doc-find']) { + fs.copyFileSync(path.join(ROOT, 'bin', bin), path.join(path.dirname(helper), bin)); + fs.chmodSync(path.join(path.dirname(helper), bin), 0o755); + } const expected = path.join(home, '.gstack/projects/canonical-override/session-unknown-design-current.md'); const wrong = path.join(home, '.gstack/projects/project/session-unknown-design-wrong.md'); for (const file of [expected, wrong]) { diff --git a/test/gstack-design-doc-find.test.ts b/test/gstack-design-doc-find.test.ts new file mode 100644 index 000000000..87444ff77 --- /dev/null +++ b/test/gstack-design-doc-find.test.ts @@ -0,0 +1,111 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { execFileSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const BIN = path.join(ROOT, 'bin/gstack-design-doc-find'); +const temps: string[] = []; + +afterEach(() => { + for (const dir of temps.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +function fixture() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-doc-find-')); + temps.push(dir); + const home = path.join(dir, 'home'); + const repo = path.join(dir, 'repo'); + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(repo, { recursive: true }); + execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: repo, timeout: 10_000 }); + const baseEnv = { PATH: process.env.PATH!, HOME: home, GIT_CEILING_DIRECTORIES: dir }; + const write = (file: string, ageSeconds: number) => { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, '# Design\n'); + const t = Date.now() / 1000 - ageSeconds; + fs.utimesSync(file, t, t); + return file; + }; + const find = (env: Record = {}, args = ['proj', 'feat-x']) => + execFileSync(BIN, args, { cwd: repo, env: { ...baseEnv, ...env }, encoding: 'utf8', timeout: 10_000 }).trim(); + return { dir, home, repo, write, find }; +} + +describe('gstack-design-doc-find', () => { + test('default root is ~/.gstack and prints nothing when no doc exists', () => { + const f = fixture(); + expect(f.find()).toBe(''); + const doc = f.write(path.join(f.home, '.gstack/projects/proj/user-main-design-20260101.md'), 100); + expect(f.find()).toBe(doc); + }); + + test('GSTACK_HOME moves the state root', () => { + const f = fixture(); + f.write(path.join(f.home, '.gstack/projects/proj/user-main-design-a.md'), 10); + const custom = path.join(f.dir, 'custom-state'); + const doc = f.write(path.join(custom, 'projects/proj/user-main-design-b.md'), 100); + expect(f.find({ GSTACK_HOME: custom })).toBe(doc); + }); + + test('a branch-specific doc wins over a newer generic doc', () => { + const f = fixture(); + const branch = f.write(path.join(f.home, '.gstack/projects/proj/user-feat-x-design-1.md'), 500); + f.write(path.join(f.home, '.gstack/projects/proj/user-main-design-2.md'), 10); + expect(f.find()).toBe(branch); + expect(f.find({}, ['proj', 'other'])).toBe(path.join(f.home, '.gstack/projects/proj/user-main-design-2.md')); + }); + + test('repo DESIGN.md wins only when at least as fresh, and beats docs/designs', () => { + const f = fixture(); + const local = f.write(path.join(f.home, '.gstack/projects/proj/user-feat-x-design-1.md'), 100); + const designs = f.write(path.join(f.repo, 'docs/designs/plan.md'), 10); + expect(f.find()).toBe(designs); + const top = f.write(path.join(f.repo, 'DESIGN.md'), 1000); + expect(f.find()).toBe(local); + fs.utimesSync(top, Date.now() / 1000, Date.now() / 1000); + expect(f.find()).toBe(top); + }); + + test('a repo doc is used when the state root has none', () => { + const f = fixture(); + const designs = f.write(path.join(f.repo, 'docs/designs/plan.md'), 1000); + expect(f.find()).toBe(designs); + }); + + test('usage errors exit 2', () => { + const f = fixture(); + expect(() => f.find({}, [])).toThrow(); + }); +}); + +describe('rendered Design Doc Check block', () => { + const rendered = fs.readFileSync(path.join(ROOT, 'plan-ceo-review/SKILL.md'), 'utf8'); + const block = rendered.slice(rendered.indexOf('DESIGN=$(~/.claude/skills/gstack/bin/gstack-design-doc-find')) + .split('\n').slice(0, 2).join('\n'); + + test('the generated skill renders one helper invocation', () => { + expect(block).toContain('gstack-design-doc-find "$SLUG" "$BRANCH"'); + expect(block).toContain('echo "Design doc found: $DESIGN"'); + expect(block).toContain('echo "No design doc found"'); + }); + + test('finds an office-hours doc under a custom GSTACK_HOME and keeps the default-root result', () => { + const f = fixture(); + const installed = path.join(f.home, '.claude/skills/gstack/bin'); + fs.mkdirSync(installed, { recursive: true }); + for (const bin of ['gstack-design-doc-find', 'gstack-paths', 'gstack-state-root.sh']) { + fs.copyFileSync(path.join(ROOT, 'bin', bin), path.join(installed, bin)); + fs.chmodSync(path.join(installed, bin), 0o755); + } + const run = (env: Record) => execFileSync('bash', ['-c', `SLUG=proj; BRANCH=main; ${block}`], + { cwd: f.repo, env: { PATH: process.env.PATH!, HOME: f.home, GIT_CEILING_DIRECTORIES: f.dir, ...env }, encoding: 'utf8', timeout: 10_000 }).trim(); + const custom = path.join(f.dir, 'custom-state'); + const doc = f.write(path.join(custom, 'projects/proj/garry-main-design-20261003-120000.md'), 60); + expect(run({ GSTACK_HOME: custom })).toBe(`Design doc found: ${doc}`); + expect(run({})).toBe('No design doc found'); + const legacy = f.write(path.join(f.home, '.gstack/projects/proj/garry-main-design-20261001-120000.md'), 60); + expect(run({})).toBe(`Design doc found: ${legacy}`); + }); +}); diff --git a/test/helpers/touchfiles-data.ts b/test/helpers/touchfiles-data.ts index 258cc3669..0d35edd87 100644 --- a/test/helpers/touchfiles-data.ts +++ b/test/helpers/touchfiles-data.ts @@ -269,7 +269,7 @@ export const E2E_TOUCHFILES: Record = { // (--max-concurrency, no retries), so worst-case cost is one pass of // each, sharing the API budget with sibling tests — not the // sequential ~+10min a local read suggests. - 'plan-mode-no-op': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'test/fixtures/auto-decide-recommendation-361c.json', + 'plan-mode-no-op': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-design-doc-find', 'scripts/resolvers/design-doc-discovery.ts', 'scripts/resolvers/spec-review.ts', 'plan-devex-review/**', 'test/fixtures/auto-decide-recommendation-361c.json', 'test/fixtures/auto-decide-target-361c.json', @@ -838,7 +838,7 @@ export const E2E_TOUCHFILES: Record = { 'test/fixtures/outside-async-task-m-events.json', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json','scripts/resolvers/learnings.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'codex/**', 'bin/gstack-codex-probe', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/design.ts', 'test/skill-e2e-autoplan-dual-voice.test.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', - 'scripts/resolvers/design-doc-discovery.ts', 'plan-ceo-review/**', 'plan-eng-review/**', 'plan-design-review/**', 'plan-devex-review/**', 'scripts/resolvers/testing.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/tasks-section.ts', 'test/helpers/autoplan-phase-observer.ts', 'scripts/resolve-codex-generation-model.ts', 'scripts/resolvers/constants.ts'], + 'scripts/resolvers/design-doc-discovery.ts', 'bin/gstack-design-doc-find', 'plan-ceo-review/**', 'plan-eng-review/**', 'plan-design-review/**', 'plan-devex-review/**', 'scripts/resolvers/testing.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/tasks-section.ts', 'test/helpers/autoplan-phase-observer.ts', 'scripts/resolve-codex-generation-model.ts', 'scripts/resolvers/constants.ts'], // Multi-provider benchmark adapters — live API smoke against real claude/codex/gemini CLIs 'benchmark-providers-live': ['bin/gstack-model-benchmark', 'test/helpers/providers/**', 'test/helpers/benchmark-runner.ts', 'test/helpers/pricing.ts', 'test/skill-e2e-benchmark-providers.test.ts'], diff --git a/test/instruction-facts.test.ts b/test/instruction-facts.test.ts index 8665ec2d0..481942ad1 100644 --- a/test/instruction-facts.test.ts +++ b/test/instruction-facts.test.ts @@ -169,10 +169,8 @@ const RATCHET_ALLOWLIST = JSON.parse(read('test/state-root-ratchet.allowlist.jso const readinessEvalReason = RATCHET_ALLOWLIST.find(entry => entry.path === 'land-and-deploy/sections/readiness-gate.md.tmpl')!.reason; const DESCRIPTION = 'user-facing description of where the data lives; no command reads or writes this literal'; const SOURCE_COMMENT = 'source comment, not rendered into any skill'; -const DESIGN_DOC_HELD = 'held at the literal path: converting design-doc discovery to $GSTACK_STATE_ROOT made plan-eng-review ask its scope gate in prose instead of AskUserQuestion (plan-mode no-op eval: 5 of 5 runs failed with it, 6 of 6 passed without); see TODOS.md'; const STATE_ROOT_ALLOWLIST: Array<{ file: string; match: string; reason: string }> = [ - { file: 'autoplan/SKILL.md.tmpl', match: 'ls -t ~/.gstack/projects/$SLUG/*-design-*.md', reason: DESIGN_DOC_HELD }, { file: 'autoplan/SKILL.md.tmpl', match: 'test plan on disk at ~/.gstack/projects/$SLUG/', reason: DESCRIPTION }, { file: 'careful/SKILL.md.tmpl', match: '`~/.gstack/projects//careful-patterns.txt` (per-project)', reason: DESCRIPTION }, { file: 'design-shotgun/SKILL.md.tmpl', match: 'v1 schema at `~/.gstack/projects/$SLUG/taste-profile.json`', reason: DESCRIPTION }, @@ -183,9 +181,6 @@ const STATE_ROOT_ALLOWLIST: Array<{ file: string; match: string; reason: string { file: 'plan-ceo-review/sections/review-sections.md.tmpl', match: 'Keep in `~/.gstack/projects/` only (local, personal reference)', reason: DESCRIPTION }, { file: 'plan-tune/SKILL.md.tmpl', match: 'Logs stay local (`~/.gstack/projects//question-log.jsonl`)', reason: DESCRIPTION }, { file: 'plan-tune/SKILL.md.tmpl', match: '`~/.gstack/projects//question-log.jsonl` — nothing leaves your', reason: DESCRIPTION }, - { file: 'scripts/resolvers/design-doc-discovery.ts', match: '* under ~/.gstack/projects//, falling back', reason: SOURCE_COMMENT }, - { file: 'scripts/resolvers/design-doc-discovery.ts', match: '_LOCALDOC=$(ls -t ~/.gstack/projects/$SLUG/*-$BRANCH-design-*.md', reason: DESIGN_DOC_HELD }, - { file: 'scripts/resolvers/design-doc-discovery.ts', match: '_LOCALDOC=$(ls -t ~/.gstack/projects/$SLUG/*-design-*.md', reason: DESIGN_DOC_HELD }, { file: 'scripts/resolvers/learnings.ts', match: '* Learnings are stored per-project at ~/.gstack/projects/{slug}/learnings.jsonl', reason: SOURCE_COMMENT }, { file: 'scripts/resolvers/preamble.ts', match: 'local JSONL append to ~/.gstack/analytics/ (inline, inspectable)', reason: SOURCE_COMMENT }, ];