#!/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); '