mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-05 05:05:08 +02:00
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) <noreply@anthropic.com>
This commit is contained in:
+39
-3
@@ -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 <key>}"
|
||||
@@ -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 <key> <value>}"
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user