mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 14:34:42 +01:00
* fix(plan-eng-review): register the structure question id so never-ask can fire The complexity gate's Original/Smaller arrangement question had no registered question_id, so each run invented a per-plan slug (one install logged ten different ids for it). --check matches ids exactly, so a stored never-ask could never fire and the question was asked every time. - Register plan-eng-review-structure as a two-way routing question. - The template names that exact id and says AUTO_DECIDE picks the recommended arrangement; the preamble's check-first rule does the rest. Wording is byte-neutral against the parity budget and the per-host pins. - --check falls back to $GSTACK_HOME/global-question-preferences.json, the file the question-preference hook already reads, when the project file has no entry for the id, so one setting covers every repo and both layers agree. A project entry still wins, the one-way and split-chain guards run first, a malformed or non-object global file is ignored with a NOTE, and --write stays per-project. - Tests for the fallback and the template contract; two wording pins updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commitfb0334eb5c) * fix(context): pin English provenance markers and ask when branch cannot discriminate Addresses three gaps from #3217: 1. context-save: markers must stay the five English literals; translated tokens break restore sorting. Decisions Made claims about file contents need evidence or an assumed Remaining Work item. 2. context-restore: unknown/translated parenthesised markers sort under Verify first as (path assumed). 3. When CURRENT_BRANCH cannot discriminate and recent candidates have different titles, ask instead of silently loading newest. Pins the new contract in the provenance prompt-byte test. Fixes #3217 (cherry picked from commit2a02647fd4) * docs: add a neutral how-to for bringing up a first deploy (#3150) /setup-deploy stops at "Nothing is deployed yet" and writes nothing, because it has no production URL or deploy trigger to record. The docs said nothing about the step in between. The maintainers chose to keep that stop vendor-neutral and asked for a neutral docs page instead (#3150, option 2). docs/howto-bring-up-a-first-deploy.md walks through the first deploy: where the app runs and how deploys start, the services it needs, env and secrets, DNS and TLS, checking that it works, and running /setup-deploy again. It names no hosting provider or deploy tool. The README Docs table and the /setup-deploy section of docs/skills.md link to it. The stop's own text is unchanged. Fixes #3150 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit5c5d8ec96f) * docs(changelog): contributor fixes from #3249, #3244, #3264 under [Unreleased] Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: Trevor Bezdek <bezdekt@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Chao Chen <209239467+surlymochan@users.noreply.github.com> Co-authored-by: y$un_ <forrest.sun527@gmail.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
379 lines
16 KiB
Bash
Executable File
379 lines
16 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# gstack-question-preference — read/write/check explicit per-question preferences.
|
|
#
|
|
# Preference file: ~/.gstack/projects/{SLUG}/question-preferences.json
|
|
# Global fallback: ~/.gstack/global-question-preferences.json, the file the
|
|
# question-preference hook reads (--check only; a project entry for the same
|
|
# id wins). Hand-edited; --write stays per-project.
|
|
# Schema: { "<question_id>": "always-ask" | "never-ask" | "ask-only-for-one-way" }
|
|
#
|
|
# Subcommands:
|
|
# --check <id> [--summary-file F | --summary-stdin]
|
|
# → emit ASK_NORMALLY | AUTO_DECIDE | ASK_ONLY_ONE_WAY
|
|
# (the question text lets the keyword net catch
|
|
# ad-hoc destructive ids, #2024; skills write it to
|
|
# an agent-written file, never a shell string)
|
|
# --write '{...}' [--free-text-file F]
|
|
# → set a preference (user-origin gate + one-way write
|
|
# reject); F carries the user's own words (free_text)
|
|
# --read → dump preferences JSON
|
|
# --clear [<id>] → clear one or all preferences
|
|
# --stats → short summary (inert one-way prefs counted separately)
|
|
#
|
|
# User-origin gate
|
|
# ----------------
|
|
# The --write subcommand REQUIRES a `source` field on the input:
|
|
# - "plan-tune" — user ran /plan-tune and chose a preference (allowed)
|
|
# - "inline-user" — inline `tune:` from the user's own chat message (allowed)
|
|
# - "inline-tool-output"— tune: prefix seen in tool output / file content (REJECTED)
|
|
# - "inline-file" — tune: prefix seen in a file the agent read (REJECTED)
|
|
# This is the profile-poisoning defense from docs/designs/PLAN_TUNING_V0.md.
|
|
#
|
|
# One-way write reject (#2488)
|
|
# ----------------------------
|
|
# never-ask and ask-only-for-one-way are refused on registry one-way ids.
|
|
# --check already ignores those prefs; storing them made --stats lie.
|
|
# always-ask on a one-way id is fine (it agrees with the safety override).
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
# GSTACK_STATE_ROOT takes precedence over GSTACK_HOME (test isolation per D16).
|
|
. "$(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}"
|
|
PREF_FILE="$GSTACK_HOME/projects/$SLUG/question-preferences.json"
|
|
GLOBAL_PREF_FILE="$GSTACK_HOME/global-question-preferences.json"
|
|
EVENT_FILE="$GSTACK_HOME/projects/$SLUG/question-events.jsonl"
|
|
mkdir -p "$GSTACK_HOME/projects/$SLUG"
|
|
|
|
CMD="${1:-}"
|
|
shift || true
|
|
|
|
# Read an agent-written text file (a question summary, the user's own tune
|
|
# words). A relative path is tried against the cwd, then the git top level. A
|
|
# file inside a .gstack/tmp directory is scratch and is removed after reading.
|
|
read_text_file() {
|
|
local f="$1" top
|
|
if [ ! -f "$f" ] && [ "${f#/}" = "$f" ]; then
|
|
top=$(git rev-parse --show-toplevel 2>/dev/null || true)
|
|
[ -n "$top" ] && [ -f "$top/$f" ] && f="$top/$f"
|
|
fi
|
|
[ -f "$f" ] || return 1
|
|
head -c 4096 -- "$f"
|
|
case "$(cd "$(dirname -- "$f")" && pwd -P)" in */.gstack/tmp) rm -f -- "$f" ;; esac
|
|
}
|
|
|
|
ensure_file() {
|
|
if [ ! -f "$PREF_FILE" ]; then
|
|
echo '{}' > "$PREF_FILE"
|
|
fi
|
|
}
|
|
|
|
# -----------------------------------------------------------------------
|
|
# --check <question_id>
|
|
# -----------------------------------------------------------------------
|
|
do_check() {
|
|
local QID="" SUMMARY_STDIN=false SUMMARY_FILE=""
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--summary-stdin) SUMMARY_STDIN=true; shift ;;
|
|
--summary-file) SUMMARY_FILE="${2:-}"; shift; [ $# -gt 0 ] && shift ;;
|
|
*) [ -z "$QID" ] && QID="$1"; shift ;;
|
|
esac
|
|
done
|
|
if [ -z "$QID" ]; then
|
|
echo "ASK_NORMALLY"
|
|
return 0
|
|
fi
|
|
# #2024: the question text feeds the keyword classifier for unregistered
|
|
# ids. Transport is stdin (not argv) — summaries carry quotes, newlines,
|
|
# and shell metacharacters an argv tail would mangle. Handed to bun via
|
|
# env so no shell re-quoting happens. Empty summary = id-only behavior.
|
|
local QSUMMARY=""
|
|
if [ -n "$SUMMARY_FILE" ]; then
|
|
# A summary that cannot be read cannot clear the keyword check: ask.
|
|
if ! QSUMMARY=$(read_text_file "$SUMMARY_FILE"); then
|
|
echo "ASK_NORMALLY"
|
|
echo "NOTE: summary file $SUMMARY_FILE is missing; asking normally." >&2
|
|
return 0
|
|
fi
|
|
elif [ "$SUMMARY_STDIN" = true ]; then
|
|
QSUMMARY=$(cat 2>/dev/null || true)
|
|
fi
|
|
ensure_file
|
|
cd "$ROOT_DIR"
|
|
PREF_FILE_PATH="$PREF_FILE" GLOBAL_PREF_FILE_PATH="$GLOBAL_PREF_FILE" QID="$QID" QSUMMARY="$QSUMMARY" bun -e "
|
|
import('./scripts/one-way-doors.ts').then((oneway) => {
|
|
const fs = require('fs');
|
|
const qid = process.env.QID;
|
|
const summary = process.env.QSUMMARY || undefined;
|
|
const prefs = JSON.parse(fs.readFileSync(process.env.PREF_FILE_PATH, 'utf-8'));
|
|
let globalPrefs = {};
|
|
try {
|
|
if (fs.existsSync(process.env.GLOBAL_PREF_FILE_PATH)) {
|
|
const parsed = JSON.parse(fs.readFileSync(process.env.GLOBAL_PREF_FILE_PATH, 'utf-8'));
|
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('not an object');
|
|
globalPrefs = parsed;
|
|
}
|
|
} catch (e) {
|
|
console.error('NOTE: global-question-preferences.json is unreadable; ignoring it.');
|
|
}
|
|
const pref = qid in prefs ? prefs[qid] : globalPrefs[qid];
|
|
|
|
// Always check one-way status first — safety overrides preferences.
|
|
// summary (when piped) lets the keyword net catch ad-hoc destructive
|
|
// questions whose id has no registry entry (#2024).
|
|
const oneWay = oneway.isOneWayDoor({ question_id: qid, summary });
|
|
|
|
if (oneWay) {
|
|
console.log('ASK_NORMALLY');
|
|
if (pref === 'never-ask') {
|
|
console.log('NOTE: one-way door overrides your never-ask preference for safety.');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Split-chain carve-out: per-option calls in N-option splits emit
|
|
// question_ids of the form <skill>-split-<option-slug>. These are
|
|
// NEVER AUTO_DECIDE-eligible regardless of stored preferences — the
|
|
// whole point of splitting is restoring user sovereignty over the
|
|
// option set. See scripts/resolvers/preamble/generate-ask-user-format.ts
|
|
// \"Handling 5+ options — split, never drop\" for the surrounding
|
|
// mechanism that generates these ids.
|
|
if (/-split-/.test(qid)) {
|
|
console.log('ASK_NORMALLY');
|
|
if (pref === 'never-ask' || pref === 'ask-only-for-one-way') {
|
|
console.log('NOTE: split-chain per-option calls always ASK_NORMALLY; your ' + pref + ' preference does not apply to options inside a sequential split.');
|
|
}
|
|
return;
|
|
}
|
|
|
|
switch (pref) {
|
|
case 'never-ask':
|
|
console.log('AUTO_DECIDE');
|
|
break;
|
|
case 'ask-only-for-one-way':
|
|
// Not one-way (we checked above) — auto-decide this two-way question.
|
|
console.log('AUTO_DECIDE');
|
|
break;
|
|
case 'always-ask':
|
|
case undefined:
|
|
case null:
|
|
console.log('ASK_NORMALLY');
|
|
break;
|
|
default:
|
|
console.log('ASK_NORMALLY');
|
|
console.log('NOTE: unknown preference value: ' + pref);
|
|
}
|
|
}).catch(err => { console.error('check:', err.message); process.exit(1); });
|
|
"
|
|
}
|
|
|
|
# -----------------------------------------------------------------------
|
|
# --write '{...}' (with user-origin gate)
|
|
# -----------------------------------------------------------------------
|
|
do_write() {
|
|
local INPUT="${1:-}" FREE_TEXT="" HAS_FREE_TEXT=""
|
|
if [ -z "$INPUT" ]; then
|
|
echo "gstack-question-preference: --write requires a JSON payload" >&2
|
|
exit 1
|
|
fi
|
|
shift
|
|
if [ "${1:-}" = "--free-text-file" ]; then
|
|
if ! FREE_TEXT=$(read_text_file "${2:-}"); then
|
|
echo "gstack-question-preference: cannot read the free-text file ${2:-}; write the user's words into it with your file-write tool, then rerun. Nothing was saved." >&2
|
|
exit 1
|
|
fi
|
|
HAS_FREE_TEXT=1
|
|
elif [ -n "${1:-}" ]; then
|
|
echo "gstack-question-preference: unexpected argument after the JSON payload: $1" >&2
|
|
exit 1
|
|
fi
|
|
ensure_file
|
|
local TMPERR
|
|
TMPERR=$(mktemp "${TMPDIR:-/tmp}/gstack-question-preference.XXXXXX") || { echo "gstack-question-preference: could not create a temp file in ${TMPDIR:-/tmp}, so the preference was not saved. Fix: point TMPDIR at a writable directory." >&2; return 1; }
|
|
# Use function-local cleanup via RETURN trap so variable lookup only happens
|
|
# while the function is on the stack (avoids EXIT-trap unbound-var race).
|
|
trap "rm -f '$TMPERR'" RETURN
|
|
|
|
set +e
|
|
local RESULT
|
|
RESULT=$(cd "$ROOT_DIR" && printf '%s' "$INPUT" | PREF_FILE_PATH="$PREF_FILE" EVENT_FILE_PATH="$EVENT_FILE" FREE_TEXT="$FREE_TEXT" HAS_FREE_TEXT="$HAS_FREE_TEXT" bun -e "
|
|
const fs = require('fs');
|
|
const raw = await Bun.stdin.text();
|
|
let j;
|
|
try { j = JSON.parse(raw); } catch { process.stderr.write('gstack-question-preference: invalid JSON\n'); process.exit(1); }
|
|
// The user's own words arrive in an agent-written file, never inside the
|
|
// shell-quoted JSON argument.
|
|
if (process.env.HAS_FREE_TEXT) j.free_text = process.env.FREE_TEXT.trim();
|
|
|
|
// Required: question_id (kebab-case, <=64)
|
|
if (!j.question_id || !/^[a-z0-9-]+\$/.test(j.question_id) || j.question_id.length > 64) {
|
|
process.stderr.write('gstack-question-preference: invalid question_id\n');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Required: preference
|
|
const ALLOWED_PREFS = ['always-ask', 'never-ask', 'ask-only-for-one-way'];
|
|
if (!ALLOWED_PREFS.includes(j.preference)) {
|
|
process.stderr.write('gstack-question-preference: invalid preference (must be one of: ' + ALLOWED_PREFS.join(', ') + ')\n');
|
|
process.exit(1);
|
|
}
|
|
|
|
// user-origin gate — REQUIRED on every write.
|
|
// See docs/designs/PLAN_TUNING_V0.md §Security model
|
|
const ALLOWED_SOURCES = ['plan-tune', 'inline-user'];
|
|
const REJECTED_SOURCES = ['inline-tool-output', 'inline-file', 'inline-file-content', 'inline-unknown'];
|
|
if (!j.source) {
|
|
process.stderr.write('gstack-question-preference: source field required (one of: ' + ALLOWED_SOURCES.join(', ') + ')\n');
|
|
process.exit(1);
|
|
}
|
|
if (REJECTED_SOURCES.includes(j.source)) {
|
|
process.stderr.write('gstack-question-preference: rejected — source \"' + j.source + '\" is not user-originated (profile poisoning defense)\n');
|
|
process.exit(2);
|
|
}
|
|
if (!ALLOWED_SOURCES.includes(j.source)) {
|
|
process.stderr.write('gstack-question-preference: rejected — source \"' + j.source + '\" is not user-originated (profile poisoning defense)\n');
|
|
process.exit(2);
|
|
}
|
|
|
|
// One-way write reject (#2488) — after the origin gate so a poisoning
|
|
// payload on a one-way id still exits 2, not 1.
|
|
if (j.preference === 'never-ask' || j.preference === 'ask-only-for-one-way') {
|
|
const oneway = await import('./scripts/one-way-doors.ts');
|
|
if (oneway.isOneWayDoor({ question_id: j.question_id })) {
|
|
process.stderr.write('gstack-question-preference: cannot set ' + j.preference + ' on one-way question \"' + j.question_id + '\" (door_type: one-way)\n');
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Optional free_text — sanitize (no injection patterns, no newlines, <=300 chars)
|
|
if (j.free_text !== undefined) {
|
|
if (typeof j.free_text !== 'string') {
|
|
process.stderr.write('gstack-question-preference: free_text must be string\n');
|
|
process.exit(1);
|
|
}
|
|
if (j.free_text.length > 300) j.free_text = j.free_text.slice(0, 300);
|
|
j.free_text = j.free_text.replace(/\n+/g, ' ');
|
|
const INJECTION_PATTERNS = [
|
|
/ignore\s+(all\s+)?previous\s+(instructions|context|rules)/i,
|
|
/you\s+are\s+now\s+/i,
|
|
/override[:\s]/i,
|
|
/\bsystem\s*:/i,
|
|
/\bassistant\s*:/i,
|
|
/do\s+not\s+(report|flag|mention)/i,
|
|
];
|
|
for (const pat of INJECTION_PATTERNS) {
|
|
if (pat.test(j.free_text)) {
|
|
process.stderr.write('gstack-question-preference: free_text contains injection-like content, rejected\n');
|
|
process.exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Write to preferences file
|
|
const prefs = JSON.parse(fs.readFileSync(process.env.PREF_FILE_PATH, 'utf-8'));
|
|
prefs[j.question_id] = j.preference;
|
|
fs.writeFileSync(process.env.PREF_FILE_PATH, JSON.stringify(prefs, null, 2));
|
|
|
|
// Also append a record to question-events.jsonl for audit + derivation.
|
|
const evt = {
|
|
ts: new Date().toISOString(),
|
|
event_type: 'preference-set',
|
|
question_id: j.question_id,
|
|
preference: j.preference,
|
|
source: j.source,
|
|
...(j.free_text ? { free_text: j.free_text } : {}),
|
|
};
|
|
fs.appendFileSync(process.env.EVENT_FILE_PATH, JSON.stringify(evt) + '\n');
|
|
|
|
console.log('OK: ' + j.question_id + ' → ' + j.preference + ' (source: ' + j.source + ')');
|
|
" 2>"$TMPERR")
|
|
local RC=$?
|
|
set -e
|
|
|
|
if [ $RC -ne 0 ]; then
|
|
cat "$TMPERR" >&2
|
|
exit $RC
|
|
fi
|
|
echo "$RESULT"
|
|
}
|
|
|
|
# -----------------------------------------------------------------------
|
|
# --read
|
|
# -----------------------------------------------------------------------
|
|
do_read() {
|
|
ensure_file
|
|
cat "$PREF_FILE"
|
|
}
|
|
|
|
# -----------------------------------------------------------------------
|
|
# --clear [<id>]
|
|
# -----------------------------------------------------------------------
|
|
do_clear() {
|
|
local QID="${1:-}"
|
|
ensure_file
|
|
if [ -z "$QID" ]; then
|
|
echo '{}' > "$PREF_FILE"
|
|
echo "OK: cleared all preferences"
|
|
else
|
|
PREF_FILE_PATH="$PREF_FILE" QID="$QID" bun -e "
|
|
const fs = require('fs');
|
|
const prefs = JSON.parse(fs.readFileSync(process.env.PREF_FILE_PATH, 'utf-8'));
|
|
if (prefs[process.env.QID] !== undefined) {
|
|
delete prefs[process.env.QID];
|
|
fs.writeFileSync(process.env.PREF_FILE_PATH, JSON.stringify(prefs, null, 2));
|
|
console.log('OK: cleared ' + process.env.QID);
|
|
} else {
|
|
console.log('NOOP: no preference set for ' + process.env.QID);
|
|
}
|
|
"
|
|
fi
|
|
}
|
|
|
|
# -----------------------------------------------------------------------
|
|
# --stats
|
|
# -----------------------------------------------------------------------
|
|
do_stats() {
|
|
ensure_file
|
|
(cd "$ROOT_DIR" && PREF_FILE_PATH="$PREF_FILE" bun -e "
|
|
import('./scripts/one-way-doors.ts').then((oneway) => {
|
|
const fs = require('fs');
|
|
const prefs = JSON.parse(fs.readFileSync(process.env.PREF_FILE_PATH, 'utf-8'));
|
|
const entries = Object.entries(prefs);
|
|
const counts = { 'always-ask': 0, 'never-ask': 0, 'ask-only-for-one-way': 0, other: 0, inert: 0 };
|
|
for (const [id, v] of entries) {
|
|
const suppressing = v === 'never-ask' || v === 'ask-only-for-one-way';
|
|
if (suppressing && oneway.isOneWayDoor({ question_id: id })) {
|
|
counts.inert++;
|
|
continue;
|
|
}
|
|
if (counts[v] !== undefined) counts[v]++;
|
|
else counts.other++;
|
|
}
|
|
console.log('TOTAL: ' + entries.length);
|
|
console.log('ALWAYS_ASK: ' + counts['always-ask']);
|
|
console.log('NEVER_ASK: ' + counts['never-ask']);
|
|
console.log('ASK_ONLY_ONE_WAY: ' + counts['ask-only-for-one-way']);
|
|
console.log('INERT_ONE_WAY: ' + counts.inert);
|
|
if (counts.other) console.log('OTHER: ' + counts.other);
|
|
}).catch(err => { console.error('stats:', err.message); process.exit(1); });
|
|
")
|
|
}
|
|
|
|
case "$CMD" in
|
|
--check) do_check "$@" ;;
|
|
--write) do_write "$@" ;;
|
|
--read|"") do_read ;;
|
|
--clear) do_clear "$@" ;;
|
|
--stats) do_stats ;;
|
|
--help|-h) sed -n '1,/^set -euo/p' "$0" | sed 's|^# \?||' ;;
|
|
*)
|
|
echo "gstack-question-preference: unknown subcommand '$CMD'" >&2
|
|
exit 1
|
|
;;
|
|
esac
|