mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-04 15:57:33 +01:00
Bins, careful/freeze hooks, setup, upgrade migrations, browse/src, design, ios-qa daemon, lib and scripts resolve the state root through bin/gstack-state-root.sh (bash) or lib/state-root.ts (TS). Bins source the twin and stop with a reinstall message when it is missing; hooks source it and never spawn gstack-paths. browse/src/config.ts and lib/cso/state.ts delegate to resolveStateRoot. Analytics writers and readers move together so the usage log stays one file. gstack-uninstall deletes state only at ~/.gstack, refuses (exit 2) when it resolves to /, $HOME or an ancestor, the checkout or the git root, and leaves any other resolved root in place with the removal command. Fixtures that copy single bins now copy the twin.
184 lines
7.6 KiB
Bash
Executable File
184 lines
7.6 KiB
Bash
Executable File
#!/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 <N> # apply Nth proposal
|
|
# gstack-distill-apply --proposal <N> --gbrain-published true
|
|
# gstack-distill-apply --list # show pending proposals
|
|
set -euo pipefail
|
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
|
|
gstack_state_root_select
|
|
GSTACK_HOME="$_gstack_sr_root"
|
|
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 <N> 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);
|
|
'
|