diff --git a/bin/gstack-skill-start b/bin/gstack-skill-start index 1b0ca808f..1b2c0daac 100755 --- a/bin/gstack-skill-start +++ b/bin/gstack-skill-start @@ -133,6 +133,9 @@ find "$_GH/sessions" -mmin +120 -type f -exec rm {} + 2>/dev/null || true _SESSIONS=$(find "$_GH/sessions" -mmin -120 -type f 2>/dev/null | wc -l | tr -d ' ') _PROACTIVE=$("$_BIN/gstack-config" get proactive 2>/dev/null || echo "core") +# A hand-edited config can hold anything; the router and preambles only know +# core | all | off (true = all, false = off), so anything else reads as core. +case "$_PROACTIVE" in core|all|off|true|false) ;; *) _PROACTIVE="core" ;; esac _PROACTIVE_PROMPTED=$([ -f "$_GH/.proactive-prompted" ] && echo "yes" || echo "no") # Branch name is embedded in concatenated JSON below; strip anything outside # the gstack-slug BRANCH alphabet so a hostile ref name can't forge fields. @@ -562,12 +565,14 @@ fi if [ "$_ACTIVATED" = "no" ]; then _FT_TIP="" case "$_FIRST_TASK" in - greenfield) _FT_TIP="Fresh repo — shape it first with \`/spec\` or \`/office-hours\`." ;; - code_node|code_python|code_rust|code_go|code_ruby|code_ios) _FT_TIP="There's code here — \`/qa\` to see it work, or \`/investigate\` if something's off." ;; + greenfield) _FT_TIP="Fresh repo — shape it first with \`/office-hours\`." ;; + code_node|code_python|code_rust|code_go|code_ruby|code_ios) _FT_TIP="There's code here — \`/qa\` to see it work, or \`/review\` on your latest diff." ;; branch_ahead) _FT_TIP="Unshipped work on this branch — \`/review\` then \`/ship\`." ;; dirty_default) _FT_TIP="Uncommitted changes — \`/review\` before committing." ;; - clean_default) _FT_TIP="Pick one: \`/spec\`, \`/investigate\`, or \`/qa\`." ;; + clean_default) _FT_TIP="Pick one: \`/office-hours\`, \`/qa\`, or \`/review\`." ;; esac + # Under off the tips are the suggestions the user turned off. + case "$_PROACTIVE" in off|false) _FT_TIP="" ;; esac if [ -n "$_FT_TIP" ]; then _emit_block first-run-tip </dev/null || true elif [ "$_FIRST_LOOP_SHOWN" = "no" ]; then - _emit_block first-loop-tip </dev/null || true + touch "$_GH/.first-loop-tip-shown" 2>/dev/null || true + fi fi # Routing injection (interactive: AUQ + CLAUDE.md append + commit). The body diff --git a/test/gstack-config-defaults.test.ts b/test/gstack-config-defaults.test.ts index 66ca10033..0e7fd4269 100644 --- a/test/gstack-config-defaults.test.ts +++ b/test/gstack-config-defaults.test.ts @@ -62,7 +62,7 @@ function defaultArms(): string[] { const body = src.slice(src.indexOf('lookup_default()')); const end = body.indexOf('\n}'); const arms: string[] = []; - // e.g. ` proactive) echo "true" ;;` or ` user_slug_at_*) echo "" ;;` + // e.g. ` proactive) echo "core" ;; # core | all | off ...` or ` user_slug_at_*) echo "" ;;` for (const m of body.slice(0, end).matchAll(/^\s{4}([a-zA-Z0-9_*]+)\)/gm)) { if (m[1] !== '*') arms.push(m[1]); } diff --git a/test/proactive-config.test.ts b/test/proactive-config.test.ts index 7d9e6b596..47981f779 100644 --- a/test/proactive-config.test.ts +++ b/test/proactive-config.test.ts @@ -59,13 +59,4 @@ describe('gstack-config proactive', () => { expect(junk.stderr).toContain('core, all, off'); expect(run(['get', 'proactive']).stdout).toBe('core'); }); - - test('the config header documents the three values and names the core four', () => { - const src = fs.readFileSync(BIN_CONFIG, 'utf-8'); - const header = src.slice(src.indexOf('# proactive:'), src.indexOf('# routing_declined:')); - expect(header).toMatch(/^# proactive: core/m); - for (const skill of ['/office-hours', '/review', '/qa', '/ship']) expect(header).toContain(skill); - expect(header).toContain('all = '); - expect(header).toContain('off = '); - }); }); diff --git a/test/proactive-skill-start-fallback.test.ts b/test/proactive-skill-start-fallback.test.ts deleted file mode 100644 index fd5fc4017..000000000 --- a/test/proactive-skill-start-fallback.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * The PROACTIVE STATUS line (GSTA-93 fix 12, proactive core default): every - * skill reads `PROACTIVE:` from bin/gstack-skill-start, which asks - * bin/gstack-config and falls back to a literal when the config binary is - * missing. The default moved from `true` to `core`. - * - * A fallback that still says `true` means a broken install (no gstack-config - * beside the script) auto-invokes every skill while the documented default is - * the core four. - * - * Value: protects=a skill run whose bin has no gstack-config reports the same - * proactive mode as the config default; fails_when=lookup_default moves (true - * to core) without the skill-start fallback literal following; why_new=no test - * ran skill-start without gstack-config beside it; seam=none (the script is - * copied next to an empty bin, the OV4 sanitize test's pattern); no_claim=how - * the model applies the value (prose, not executed here) - * - * Regression proof — fails at HEAD: yes · passes at base: yes · passes after fix: yes - */ -import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; - -import { runBin } from './helpers/run-bin'; - -const ROOT = path.resolve(import.meta.dir, '..'); -const BIN = path.join(ROOT, 'bin'); - -let tmpHome: string; - -beforeAll(() => { - tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-proactive-ss-home-')); -}); - -afterAll(() => { - fs.rmSync(tmpHome, { recursive: true, force: true }); -}); - -function freshState(config: string): string { - const state = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-proactive-ss-state-')); - fs.writeFileSync(path.join(state, 'config.yaml'), config); - return state; -} - -function runStart(script: string, state: string): string { - const r = runBin(script, ['--skill', 'testskill'], { - cwd: tmpHome, - timeoutMs: 30_000, - env: { - PATH: process.env.PATH, - HOME: tmpHome, - GSTACK_HOME: state, - GSTACK_STATE_DIR: state, - GSTACK_LEASE_DIR: path.join(state, 'leases'), - }, - }); - expect(r.status, r.stderr).toBe(0); - return r.stdout; -} - -const proactiveLine = (out: string) => out.match(/^PROACTIVE: (.*)$/m)?.[1]; - -describe('gstack-skill-start PROACTIVE fallback', () => { - test('regression: with no gstack-config beside it, the fallback reports the config default, not a stale literal', () => { - const fakeBin = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-proactive-ss-bin-')); - const state = freshState('update_check: false\n'); - try { - for (const f of ['gstack-skill-start', 'gstack-state-root.sh']) fs.copyFileSync(path.join(BIN, f), path.join(fakeBin, f)); - fs.chmodSync(path.join(fakeBin, 'gstack-skill-start'), 0o755); - const configDefault = runBin(path.join(BIN, 'gstack-config'), ['get', 'proactive'], { gstackHome: state, cwd: ROOT, trim: true }).stdout; - expect(configDefault).not.toBe(''); - expect(proactiveLine(runStart(path.join(fakeBin, 'gstack-skill-start'), state))).toBe(configDefault); - } finally { - fs.rmSync(fakeBin, { recursive: true, force: true }); - fs.rmSync(state, { recursive: true, force: true }); - } - }); -}); diff --git a/test/proactive-skill-start.test.ts b/test/proactive-skill-start.test.ts index 72d43d55a..2f7c77b48 100644 --- a/test/proactive-skill-start.test.ts +++ b/test/proactive-skill-start.test.ts @@ -5,11 +5,22 @@ * missing. The default moved from `true` to `core`. * * Value: protects=the PROACTIVE STATUS line a skill reads carries the stored - * proactive value and core when unset; fails_when=gstack-skill-start stops - * consulting gstack-config for proactive or the default moves back to true; - * why_new=proactive-config.test.ts exercises gstack-config alone and + * proactive value, core when unset, and core for a value the router does not + * know; fails_when=gstack-skill-start stops consulting gstack-config for + * proactive, the default moves back to true, or a hand-edited junk value leaks + * into STATUS; why_new=proactive-config.test.ts exercises gstack-config alone and * gstack-skill-start.test.ts asserts only that the key is emitted; seam=none; * no_claim=how the model applies the value (prose, not executed here) + * + * The fallback test: a broken install (no gstack-config beside the script) + * must report the same mode as the config default. + * Value: protects=a skill run whose bin has no gstack-config reports the same + * proactive mode as the config default; fails_when=lookup_default moves (true + * to core) without the skill-start fallback literal following; why_new=no test + * ran skill-start without gstack-config beside it; seam=none (the script is + * copied next to an empty bin, the OV4 sanitize test's pattern); no_claim=how + * the model applies the value (prose, not executed here) + * Regression proof — fails at HEAD (b115142): yes · passes at base: yes · passes after fix (9e22d7f): yes */ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; import * as fs from 'fs'; @@ -62,6 +73,7 @@ describe('gstack-skill-start PROACTIVE status line', () => { ['update_check: false\nproactive: all\n', 'all'], ['update_check: false\nproactive: off\n', 'off'], ['update_check: false\nproactive: true\n', 'true'], + ['update_check: false\nproactive: on\n', 'core'], ]; for (const [config, expected] of cases) { const state = freshState(config); @@ -72,4 +84,19 @@ describe('gstack-skill-start PROACTIVE status line', () => { } } }); + + test('regression: with no gstack-config beside it, the fallback reports the config default, not a stale literal', () => { + const fakeBin = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-proactive-ss-bin-')); + const state = freshState('update_check: false\n'); + try { + for (const f of ['gstack-skill-start', 'gstack-state-root.sh']) fs.copyFileSync(path.join(BIN, f), path.join(fakeBin, f)); + fs.chmodSync(path.join(fakeBin, 'gstack-skill-start'), 0o755); + const configDefault = runBin(path.join(BIN, 'gstack-config'), ['get', 'proactive'], { gstackHome: state, cwd: ROOT, trim: true }).stdout; + expect(configDefault).not.toBe(''); + expect(proactiveLine(runStart(path.join(fakeBin, 'gstack-skill-start'), state))).toBe(configDefault); + } finally { + fs.rmSync(fakeBin, { recursive: true, force: true }); + fs.rmSync(state, { recursive: true, force: true }); + } + }); });