From 241be5c352dffea17fa43f9af2a570f2d44d4dc3 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 27 May 2026 07:50:01 -0700 Subject: [PATCH] =?UTF-8?q?feat(bin):=20gstack-distill-apply=20=E2=80=94?= =?UTF-8?q?=20apply=20distillation=20proposals=20with=20gbrain=20tag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan-tune cathedral T11. Bin that applies a single user-approved proposal from distillation-proposals.json to the right surface: - memory-nugget → appended to ~/.gstack/free-text-memory.json (durable local source-of-truth; gbrain is mirror when configured). - preference → routed through gstack-question-preference --write with source=plan-tune (clears the user-origin gate). - declared-nudge → atomic update to developer-profile.json declared dim, small=0.05, medium=0.10, large=0.15, clamped to [0, 1]. Why a separate bin (not inline in the skill template): /plan-tune's apply step needs to be invokable from any host (Claude, Codex, etc) and must write to multiple state files atomically. A bin centralizes the schema + clamp logic; the skill template just calls it after user Y. gbrain coordination: --gbrain-published true marks the nugget so /plan-tune stats can show "12 nuggets, 8 mirrored to gbrain". The skill template invokes mcp__gbrain__put_page / extract_facts / add_tag in the same turn (those are MCP tools, not CLI-callable) before calling this bin. Local file remains canonical so the PreToolUse hook injection path (T12) doesn't depend on gbrain availability. Subcommands: gstack-distill-apply --list # show pending proposals gstack-distill-apply --proposal # apply, file fallback gstack-distill-apply --proposal --gbrain-published true Applied proposals get applied_at + gbrain_published stamped on them so re-running --list shows only unconsumed ones. 11 unit tests cover --list (all three kinds + quotes), memory-nugget append + non-clobber, preference routing through the gate-respecting bin, declared-nudge math (medium=0.10, small=0.05, large=0.15, clamp at [0,1]), proposal mark-applied with gbrain flag, and error paths (bad index, missing --proposal). Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/gstack-distill-apply | 181 ++++++++++++++++++++++ test/distill-apply.test.ts | 300 +++++++++++++++++++++++++++++++++++++ 2 files changed, 481 insertions(+) create mode 100755 bin/gstack-distill-apply create mode 100644 test/distill-apply.test.ts diff --git a/bin/gstack-distill-apply b/bin/gstack-distill-apply new file mode 100755 index 000000000..5b97da0aa --- /dev/null +++ b/bin/gstack-distill-apply @@ -0,0 +1,181 @@ +#!/usr/bin/env bash +# gstack-distill-apply — apply a single distillation proposal after user Y. +# +# Plan-tune cathedral T11. Reads distillation-proposals.json, applies the +# Nth proposal to the right surface: +# +# preference → gstack-question-preference --write +# declared-nudge → atomic update to ~/.gstack/developer-profile.json declared +# memory-nugget → append to ~/.gstack/free-text-memory.json (local fallback) +# +# Always confirm before calling this from the skill — the bin assumes the user +# already approved (Codex #15 trust boundary). The skill template (/plan-tune +# distill review section) handles the confirm UX. +# +# gbrain integration: when gbrain is configured, the skill template ALSO +# invokes mcp__gbrain__put_page / extract_facts / add_tag in the same turn +# (those are MCP tools, not CLI-callable). Pass --gbrain-published true to +# mark the proposal as mirrored to gbrain. The local file always gets the +# write so it's the durable source-of-truth even on machines without gbrain. +# +# Usage: +# gstack-distill-apply --proposal # apply Nth proposal +# gstack-distill-apply --proposal --gbrain-published true +# gstack-distill-apply --list # show pending proposals +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}" +eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null || true)" +SLUG="${SLUG:-unknown}" +PROJECT_DIR="$GSTACK_HOME/projects/$SLUG" +PROPOSAL_FILE="$PROJECT_DIR/distillation-proposals.json" +MEMORY_FILE="$GSTACK_HOME/free-text-memory.json" +PROFILE_FILE="$GSTACK_HOME/developer-profile.json" + +ACTION="apply" +PROPOSAL_IDX="" +GBRAIN_PUBLISHED="false" + +while [ $# -gt 0 ]; do + case "$1" in + --proposal) PROPOSAL_IDX="$2"; shift 2 ;; + --gbrain-published) GBRAIN_PUBLISHED="$2"; shift 2 ;; + --list) ACTION="list"; shift ;; + --help|-h) + sed -n '1,/^set -euo/p' "$0" | sed 's|^# \?||' + exit 0 + ;; + *) echo "unknown arg: $1" >&2; exit 1 ;; + esac +done + +if [ ! -f "$PROPOSAL_FILE" ]; then + echo "NO_PROPOSALS: $PROPOSAL_FILE missing — run gstack-distill-free-text first" + exit 0 +fi + +if [ "$ACTION" = "list" ]; then + PROPOSAL_FILE_PATH="$PROPOSAL_FILE" bun -e ' + const fs = require("fs"); + const p = JSON.parse(fs.readFileSync(process.env.PROPOSAL_FILE_PATH, "utf-8")); + const proposals = p.proposals || []; + if (proposals.length === 0) { console.log("(no proposals)"); process.exit(0); } + console.log("GENERATED: " + p.generated_at); + console.log("SOURCE_EVENTS: " + (p.source_event_count || 0)); + proposals.forEach((pr, i) => { + console.log(""); + console.log("[" + i + "] " + (pr.kind || "?") + " (confidence: " + (pr.confidence || "?") + ")"); + if (pr.rationale) console.log(" rationale: " + pr.rationale); + if (pr.kind === "preference") { + console.log(" question_id: " + pr.question_id); + console.log(" preference: " + pr.preference); + } else if (pr.kind === "declared-nudge") { + console.log(" dimension: " + pr.dimension); + console.log(" direction: " + pr.direction + " (" + (pr.magnitude || "?") + ")"); + } else if (pr.kind === "memory-nugget") { + console.log(" nugget: " + pr.nugget); + console.log(" signal_keys: " + JSON.stringify(pr.applies_to_signal_keys || [])); + } + if (pr.source_quotes && pr.source_quotes.length) { + console.log(" quotes:"); + pr.source_quotes.forEach((q) => console.log(" - \"" + q + "\"")); + } + }); + ' + exit 0 +fi + +if [ -z "$PROPOSAL_IDX" ]; then + echo "--proposal required" >&2 + exit 1 +fi + +# Apply via bun. Each kind has its own surface. +mkdir -p "$PROJECT_DIR" +PROPOSAL_IDX="$PROPOSAL_IDX" \ +PROPOSAL_FILE_PATH="$PROPOSAL_FILE" \ +MEMORY_FILE_PATH="$MEMORY_FILE" \ +PROFILE_FILE_PATH="$PROFILE_FILE" \ +PREF_BIN="$SCRIPT_DIR/gstack-question-preference" \ +GBRAIN_PUBLISHED="$GBRAIN_PUBLISHED" \ +bun -e ' + const fs = require("fs"); + const { spawnSync } = require("child_process"); + const idx = parseInt(process.env.PROPOSAL_IDX, 10); + const p = JSON.parse(fs.readFileSync(process.env.PROPOSAL_FILE_PATH, "utf-8")); + const proposals = p.proposals || []; + if (!Number.isInteger(idx) || idx < 0 || idx >= proposals.length) { + process.stderr.write("invalid --proposal index " + idx + " (have " + proposals.length + ")\n"); + process.exit(1); + } + const pr = proposals[idx]; + + const stamp = new Date().toISOString(); + + // Memory-nugget: always write to local file (durable source-of-truth even + // when gbrain is configured — gbrain is mirror, file is canon for the + // PreToolUse hook injection path in Layer 8). + if (pr.kind === "memory-nugget") { + const memPath = process.env.MEMORY_FILE_PATH; + let mem = { nuggets: [] }; + try { mem = JSON.parse(fs.readFileSync(memPath, "utf-8")); } catch {} + if (!Array.isArray(mem.nuggets)) mem.nuggets = []; + mem.nuggets.push({ + nugget: pr.nugget, + applies_to_signal_keys: pr.applies_to_signal_keys || [], + applied_at: stamp, + gbrain_published: process.env.GBRAIN_PUBLISHED === "true", + source_quotes: pr.source_quotes || [], + }); + const tmp = memPath + ".tmp"; + fs.writeFileSync(tmp, JSON.stringify(mem, null, 2)); + fs.renameSync(tmp, memPath); + console.log("APPLIED: memory-nugget appended to " + memPath); + } + + // Preference: route through gstack-question-preference for the user-origin + // gate + event audit trail. source=plan-tune is the allowed value since + // the user opt-in came from inside /plan-tune. + if (pr.kind === "preference") { + const res = spawnSync(process.env.PREF_BIN, [ + "--write", + JSON.stringify({ + question_id: pr.question_id, + preference: pr.preference, + source: "plan-tune", + free_text: (pr.source_quotes || []).join(" | ").slice(0, 300), + }), + ], { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], timeout: 5000 }); + if (res.status !== 0) { + process.stderr.write("preference apply failed: " + (res.stderr || res.stdout) + "\n"); + process.exit(1); + } + console.log("APPLIED: preference " + pr.question_id + " → " + pr.preference); + } + + // Declared-nudge: atomic update to developer-profile.json declared. Magnitude + // tiers: small=0.05, medium=0.10, large=0.15. Clamp to [0, 1]. + if (pr.kind === "declared-nudge") { + const mag = { small: 0.05, medium: 0.10, large: 0.15 }[pr.magnitude || "small"] || 0.05; + const delta = pr.direction === "down" ? -mag : mag; + const profilePath = process.env.PROFILE_FILE_PATH; + let profile = {}; + try { profile = JSON.parse(fs.readFileSync(profilePath, "utf-8")); } catch {} + profile.declared = profile.declared || {}; + const cur = typeof profile.declared[pr.dimension] === "number" ? profile.declared[pr.dimension] : 0.5; + const next = Math.max(0, Math.min(1, cur + delta)); + profile.declared[pr.dimension] = +next.toFixed(3); + profile.declared_at = stamp; + const tmp = profilePath + ".tmp"; + fs.writeFileSync(tmp, JSON.stringify(profile, null, 2)); + fs.renameSync(tmp, profilePath); + console.log("APPLIED: declared." + pr.dimension + " " + cur + " → " + profile.declared[pr.dimension]); + } + + // Mark the proposal as applied so /plan-tune list shows it consumed. + pr.applied_at = stamp; + pr.gbrain_published = process.env.GBRAIN_PUBLISHED === "true"; + const tmp = process.env.PROPOSAL_FILE_PATH + ".tmp"; + fs.writeFileSync(tmp, JSON.stringify(p, null, 2)); + fs.renameSync(tmp, process.env.PROPOSAL_FILE_PATH); +' diff --git a/test/distill-apply.test.ts b/test/distill-apply.test.ts new file mode 100644 index 000000000..e46781c21 --- /dev/null +++ b/test/distill-apply.test.ts @@ -0,0 +1,300 @@ +/** + * gstack-distill-apply — Layer 8 proposal application (plan-tune cathedral T11). + * + * Verifies the three apply paths: + * - memory-nugget → appended to ~/.gstack/free-text-memory.json (local + * source-of-truth; gbrain is mirror when configured). + * - preference → routed through gstack-question-preference with + * source=plan-tune (user-origin gate cleared). + * - declared-nudge → atomic update to developer-profile.json declared dim, + * small=0.05, medium=0.10, large=0.15, clamped to [0,1]. + * Plus: + * - --list shows proposals with kind, confidence, rationale, quotes. + * - Applied proposals get applied_at + gbrain_published flag. + * - Bad --proposal index errors with non-zero exit. + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { spawnSync } from 'child_process'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const BIN = path.join(ROOT, 'bin', 'gstack-distill-apply'); + +let stateRoot: string; +let fixtureCwd: string; +let cwdSlug: string; +let proposalFile: string; + +beforeEach(() => { + stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-apply-')); + cwdSlug = 'apply-fixture'; + fixtureCwd = path.join(stateRoot, cwdSlug); + fs.mkdirSync(fixtureCwd, { recursive: true }); + fs.mkdirSync(path.join(stateRoot, 'projects', cwdSlug), { recursive: true }); + proposalFile = path.join(stateRoot, 'projects', cwdSlug, 'distillation-proposals.json'); +}); + +afterEach(() => { + fs.rmSync(stateRoot, { recursive: true, force: true }); +}); + +function writeProposals(proposals: Array>): void { + fs.writeFileSync( + proposalFile, + JSON.stringify( + { generated_at: new Date().toISOString(), source_event_count: 1, proposals }, + null, + 2, + ), + ); +} + +function run(args: string[]): { stdout: string; stderr: string; status: number } { + const env: Record = {}; + for (const [k, v] of Object.entries(process.env)) { + if (v !== undefined) env[k] = v; + } + env.GSTACK_STATE_ROOT = stateRoot; + env.GSTACK_QUESTION_LOG_NO_DERIVE = '1'; + delete env.GSTACK_HOME; + const res = spawnSync(BIN, args, { env, encoding: 'utf-8', cwd: fixtureCwd }); + return { + stdout: res.stdout ?? '', + stderr: res.stderr ?? '', + status: res.status ?? -1, + }; +} + +// ---------------------------------------------------------------------- +// --list +// ---------------------------------------------------------------------- + +describe('--list', () => { + test('handles missing proposals file', () => { + const r = run(['--list']); + expect(r.status).toBe(0); + expect(r.stdout).toMatch(/NO_PROPOSALS/); + }); + + test('renders all 3 kinds + source quotes', () => { + writeProposals([ + { + kind: 'preference', + confidence: 0.9, + question_id: 'ship-changelog-voice-polish', + preference: 'never-ask', + rationale: 'user repeatedly skipped this', + source_quotes: ['skip the polish for typo PRs'], + }, + { + kind: 'declared-nudge', + confidence: 0.85, + dimension: 'scope_appetite', + direction: 'up', + magnitude: 'medium', + }, + { + kind: 'memory-nugget', + confidence: 0.95, + nugget: 'User prefers complete edge cases', + applies_to_signal_keys: ['scope-appetite'], + }, + ]); + const r = run(['--list']); + expect(r.status).toBe(0); + expect(r.stdout).toContain('preference'); + expect(r.stdout).toContain('declared-nudge'); + expect(r.stdout).toContain('memory-nugget'); + expect(r.stdout).toContain('skip the polish for typo PRs'); + expect(r.stdout).toContain('scope-appetite'); + }); +}); + +// ---------------------------------------------------------------------- +// memory-nugget application +// ---------------------------------------------------------------------- + +describe('memory-nugget apply', () => { + test('appends to ~/.gstack/free-text-memory.json with full metadata', () => { + writeProposals([ + { + kind: 'memory-nugget', + confidence: 0.9, + nugget: 'User prefers verbose explanations with tradeoffs', + applies_to_signal_keys: ['detail-preference'], + source_quotes: ['always explain the tradeoffs'], + }, + ]); + const r = run(['--proposal', '0', '--gbrain-published', 'true']); + expect(r.status).toBe(0); + expect(r.stdout).toContain('APPLIED: memory-nugget'); + + const memPath = path.join(stateRoot, 'free-text-memory.json'); + const mem = JSON.parse(fs.readFileSync(memPath, 'utf-8')); + expect(mem.nuggets.length).toBe(1); + expect(mem.nuggets[0].nugget).toContain('verbose explanations'); + expect(mem.nuggets[0].applies_to_signal_keys).toEqual(['detail-preference']); + expect(mem.nuggets[0].gbrain_published).toBe(true); + expect(mem.nuggets[0].source_quotes).toEqual(['always explain the tradeoffs']); + }); + + test('appends without clobbering existing nuggets', () => { + fs.writeFileSync( + path.join(stateRoot, 'free-text-memory.json'), + JSON.stringify({ nuggets: [{ nugget: 'pre-existing', applies_to_signal_keys: [] }] }), + ); + writeProposals([ + { + kind: 'memory-nugget', + confidence: 0.9, + nugget: 'new nugget', + applies_to_signal_keys: [], + }, + ]); + run(['--proposal', '0']); + const mem = JSON.parse( + fs.readFileSync(path.join(stateRoot, 'free-text-memory.json'), 'utf-8'), + ); + expect(mem.nuggets.length).toBe(2); + expect(mem.nuggets[0].nugget).toBe('pre-existing'); + expect(mem.nuggets[1].nugget).toBe('new nugget'); + }); +}); + +// ---------------------------------------------------------------------- +// preference application +// ---------------------------------------------------------------------- + +describe('preference apply', () => { + test('routes through gstack-question-preference with source=plan-tune', () => { + writeProposals([ + { + kind: 'preference', + confidence: 0.9, + question_id: 'ship-changelog-voice-polish', + preference: 'never-ask', + source_quotes: ['skip the polish for typo PRs'], + }, + ]); + const r = run(['--proposal', '0']); + expect(r.status).toBe(0); + expect(r.stdout).toContain('APPLIED: preference'); + + const prefPath = path.join(stateRoot, 'projects', cwdSlug, 'question-preferences.json'); + const prefs = JSON.parse(fs.readFileSync(prefPath, 'utf-8')); + expect(prefs['ship-changelog-voice-polish']).toBe('never-ask'); + }); +}); + +// ---------------------------------------------------------------------- +// declared-nudge application +// ---------------------------------------------------------------------- + +describe('declared-nudge apply', () => { + test('medium up nudge on unset dim → 0.5 + 0.10 = 0.6', () => { + writeProposals([ + { + kind: 'declared-nudge', + confidence: 0.9, + dimension: 'scope_appetite', + direction: 'up', + magnitude: 'medium', + }, + ]); + run(['--proposal', '0']); + const profile = JSON.parse( + fs.readFileSync(path.join(stateRoot, 'developer-profile.json'), 'utf-8'), + ); + expect(profile.declared.scope_appetite).toBe(0.6); + }); + + test('small down nudge on existing value', () => { + fs.writeFileSync( + path.join(stateRoot, 'developer-profile.json'), + JSON.stringify({ declared: { scope_appetite: 0.8 } }), + ); + writeProposals([ + { + kind: 'declared-nudge', + confidence: 0.9, + dimension: 'scope_appetite', + direction: 'down', + magnitude: 'small', + }, + ]); + run(['--proposal', '0']); + const profile = JSON.parse( + fs.readFileSync(path.join(stateRoot, 'developer-profile.json'), 'utf-8'), + ); + expect(profile.declared.scope_appetite).toBe(0.75); + }); + + test('clamps to [0, 1]', () => { + fs.writeFileSync( + path.join(stateRoot, 'developer-profile.json'), + JSON.stringify({ declared: { scope_appetite: 0.95 } }), + ); + writeProposals([ + { + kind: 'declared-nudge', + confidence: 0.9, + dimension: 'scope_appetite', + direction: 'up', + magnitude: 'large', + }, + ]); + run(['--proposal', '0']); + const profile = JSON.parse( + fs.readFileSync(path.join(stateRoot, 'developer-profile.json'), 'utf-8'), + ); + expect(profile.declared.scope_appetite).toBe(1); + }); +}); + +// ---------------------------------------------------------------------- +// Proposal marked applied +// ---------------------------------------------------------------------- + +describe('proposal marked applied', () => { + test('applied_at + gbrain_published written back to proposals.json', () => { + writeProposals([ + { + kind: 'memory-nugget', + confidence: 0.9, + nugget: 'something', + applies_to_signal_keys: [], + }, + ]); + run(['--proposal', '0', '--gbrain-published', 'true']); + const p = JSON.parse(fs.readFileSync(proposalFile, 'utf-8')); + expect(p.proposals[0].applied_at).toBeTruthy(); + expect(p.proposals[0].gbrain_published).toBe(true); + }); +}); + +// ---------------------------------------------------------------------- +// Error paths +// ---------------------------------------------------------------------- + +describe('error paths', () => { + test('bad --proposal index exits non-zero', () => { + writeProposals([ + { kind: 'memory-nugget', confidence: 0.9, nugget: 'x', applies_to_signal_keys: [] }, + ]); + const r = run(['--proposal', '99']); + expect(r.status).not.toBe(0); + expect(r.stderr).toContain('invalid --proposal'); + }); + + test('missing --proposal exits non-zero', () => { + writeProposals([ + { kind: 'memory-nugget', confidence: 0.9, nugget: 'x', applies_to_signal_keys: [] }, + ]); + const r = run([]); + expect(r.status).not.toBe(0); + expect(r.stderr).toContain('--proposal'); + }); +});