Files
gstack/bin/gstack-question-preference
T
afac8dfd87 v1.91.151.0 fix: contributor ports — plan-eng-review structure id, context checkpoint gaps, first-deploy how-to (#3299)
* 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 commit fb0334eb5c)

* 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 commit 2a02647fd4)

* 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 commit 5c5d8ec96f)

* 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>
2026-10-19 19:44:10 +00:00

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