From 22582df24b78dbc6998a921bb3d04badb1081f3a Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 29 Mar 2026 21:03:03 -0700 Subject: [PATCH] feat: annotated config file + routing injection tests gstack-config now writes a documented header on first config creation with every supported key explained (proactive, telemetry, auto_upgrade, skill_prefix, routing_declined, codex_reviews, skip_eng_review, etc.). Users can edit ~/.gstack/config.yaml directly, anytime. Also fixes grep to use ^KEY: anchoring so commented header lines don't shadow real config values. Tests added: - 7 new gstack-config tests (annotated header, no duplication, comment safety, routing_declined get/set/reset) - 6 new gen-skill-docs tests (preamble routing injection: bash checks, config reads, AskUserQuestion, decline persistence, routing rules) Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/gstack-config | 42 ++++++++++++++++++++-- browse/test/gstack-config.test.ts | 58 +++++++++++++++++++++++++++++++ test/gen-skill-docs.test.ts | 40 +++++++++++++++++++++ 3 files changed, 137 insertions(+), 3 deletions(-) diff --git a/bin/gstack-config b/bin/gstack-config index 821a342a..907dd784 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -13,6 +13,38 @@ set -euo pipefail STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" CONFIG_FILE="$STATE_DIR/config.yaml" +# Annotated header for new config files. Written once on first `set`. +CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on next skill run. +# Docs: https://github.com/garrytan/gstack +# +# ─── Behavior ──────────────────────────────────────────────────────── +# proactive: true # Auto-invoke skills when your request matches one. +# # Set to false to only run skills you type explicitly. +# +# routing_declined: false # Set to true to skip the CLAUDE.md routing injection +# # prompt. Set back to false to be asked again. +# +# ─── Telemetry ─────────────────────────────────────────────────────── +# telemetry: anonymous # off | anonymous | community +# # off — no data sent, no local analytics +# # anonymous — counter only, no device ID +# # community — usage data + stable device ID +# +# ─── Updates ───────────────────────────────────────────────────────── +# auto_upgrade: false # true = silently upgrade on session start +# update_check: true # false = suppress version check notifications +# +# ─── Skill naming ──────────────────────────────────────────────────── +# skill_prefix: false # true = namespace skills as /gstack-qa, /gstack-ship +# # false = short names /qa, /ship +# +# ─── Advanced ──────────────────────────────────────────────────────── +# codex_reviews: enabled # disabled = skip Codex adversarial reviews in /ship +# gstack_contributor: false # true = file field reports when gstack misbehaves +# skip_eng_review: false # true = skip eng review gate in /ship (not recommended) +# +' + case "${1:-}" in get) KEY="${2:?Usage: gstack-config get }" @@ -21,7 +53,7 @@ case "${1:-}" in echo "Error: key must contain only alphanumeric characters and underscores" >&2 exit 1 fi - grep -F "${KEY}:" "$CONFIG_FILE" 2>/dev/null | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true + grep -E "^${KEY}:" "$CONFIG_FILE" 2>/dev/null | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true ;; set) KEY="${2:?Usage: gstack-config set }" @@ -32,12 +64,16 @@ case "${1:-}" in exit 1 fi mkdir -p "$STATE_DIR" + # Write annotated header on first creation + if [ ! -f "$CONFIG_FILE" ]; then + printf '%s' "$CONFIG_HEADER" > "$CONFIG_FILE" + fi # Escape sed special chars in value and drop embedded newlines ESC_VALUE="$(printf '%s' "$VALUE" | head -1 | sed 's/[&/\]/\\&/g')" - if grep -qF "${KEY}:" "$CONFIG_FILE" 2>/dev/null; then + if grep -qE "^${KEY}:" "$CONFIG_FILE" 2>/dev/null; then # Portable in-place edit (BSD sed uses -i '', GNU sed uses -i without arg) _tmpfile="$(mktemp "${CONFIG_FILE}.XXXXXX")" - sed "s/^${KEY}:.*/${KEY}: ${ESC_VALUE}/" "$CONFIG_FILE" > "$_tmpfile" && mv "$_tmpfile" "$CONFIG_FILE" + sed "/^${KEY}:/s/.*/${KEY}: ${ESC_VALUE}/" "$CONFIG_FILE" > "$_tmpfile" && mv "$_tmpfile" "$CONFIG_FILE" else echo "${KEY}: ${VALUE}" >> "$CONFIG_FILE" fi diff --git a/browse/test/gstack-config.test.ts b/browse/test/gstack-config.test.ts index d3efc1ce..a00af609 100644 --- a/browse/test/gstack-config.test.ts +++ b/browse/test/gstack-config.test.ts @@ -135,4 +135,62 @@ describe('gstack-config', () => { const { stdout } = run(['get', 'test_special']); expect(stdout).toBe('a/b&c\\d'); }); + + // ─── annotated header ────────────────────────────────────── + test('first set writes annotated header with docs', () => { + run(['set', 'telemetry', 'off']); + const content = readFileSync(join(stateDir, 'config.yaml'), 'utf-8'); + expect(content).toContain('# gstack configuration'); + expect(content).toContain('edit freely'); + expect(content).toContain('proactive:'); + expect(content).toContain('telemetry:'); + expect(content).toContain('auto_upgrade:'); + expect(content).toContain('skill_prefix:'); + expect(content).toContain('routing_declined:'); + expect(content).toContain('codex_reviews:'); + expect(content).toContain('skip_eng_review:'); + }); + + test('header written only once, not duplicated on second set', () => { + run(['set', 'foo', 'bar']); + run(['set', 'baz', 'qux']); + const content = readFileSync(join(stateDir, 'config.yaml'), 'utf-8'); + const headerCount = (content.match(/# gstack configuration/g) || []).length; + expect(headerCount).toBe(1); + }); + + test('header does not break get on commented-out keys', () => { + run(['set', 'telemetry', 'community']); + // Header contains "# telemetry: anonymous" as a comment example. + // get should return the real value, not the comment. + const { stdout } = run(['get', 'telemetry']); + expect(stdout).toBe('community'); + }); + + test('existing config file is not overwritten with header', () => { + writeFileSync(join(stateDir, 'config.yaml'), 'existing: value\n'); + run(['set', 'new_key', 'new_value']); + const content = readFileSync(join(stateDir, 'config.yaml'), 'utf-8'); + expect(content).toContain('existing: value'); + expect(content).not.toContain('# gstack configuration'); + }); + + // ─── routing_declined ────────────────────────────────────── + test('routing_declined defaults to empty (not set)', () => { + const { stdout } = run(['get', 'routing_declined']); + expect(stdout).toBe(''); + }); + + test('routing_declined can be set and read', () => { + run(['set', 'routing_declined', 'true']); + const { stdout } = run(['get', 'routing_declined']); + expect(stdout).toBe('true'); + }); + + test('routing_declined can be reset to false', () => { + run(['set', 'routing_declined', 'true']); + run(['set', 'routing_declined', 'false']); + const { stdout } = run(['get', 'routing_declined']); + expect(stdout).toBe('false'); + }); }); diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index b3aef8f2..c2c96d5c 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -1247,6 +1247,46 @@ describe('parameterized resolver support', () => { }); }); +// --- Preamble routing injection tests --- + +describe('preamble routing injection', () => { + const shipContent = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8'); + + test('preamble bash checks for routing section in CLAUDE.md', () => { + expect(shipContent).toContain('grep -q "## Skill routing" CLAUDE.md'); + expect(shipContent).toContain('HAS_ROUTING'); + }); + + test('preamble bash reads routing_declined config', () => { + expect(shipContent).toContain('routing_declined'); + expect(shipContent).toContain('ROUTING_DECLINED'); + }); + + test('preamble includes routing injection AskUserQuestion', () => { + expect(shipContent).toContain('Add routing rules to CLAUDE.md'); + expect(shipContent).toContain("I'll invoke skills manually"); + }); + + test('routing injection respects prior decline', () => { + expect(shipContent).toContain('ROUTING_DECLINED'); + expect(shipContent).toMatch(/routing_declined.*true/); + }); + + test('routing injection only fires when all conditions met', () => { + // Must be: HAS_ROUTING=no AND ROUTING_DECLINED=false AND PROACTIVE_PROMPTED=yes + expect(shipContent).toContain('HAS_ROUTING'); + expect(shipContent).toContain('ROUTING_DECLINED'); + expect(shipContent).toContain('PROACTIVE_PROMPTED'); + }); + + test('routing section content includes key routing rules', () => { + expect(shipContent).toContain('invoke office-hours'); + expect(shipContent).toContain('invoke investigate'); + expect(shipContent).toContain('invoke ship'); + expect(shipContent).toContain('invoke qa'); + }); +}); + // --- {{DESIGN_OUTSIDE_VOICES}} resolver tests --- describe('DESIGN_OUTSIDE_VOICES resolver', () => {