forked from garrytan/gstack
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgstack-question-log
More file actions
executable file
·250 lines (224 loc) · 9.7 KB
/
Copy pathgstack-question-log
File metadata and controls
executable file
·250 lines (224 loc) · 9.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
#!/usr/bin/env bash
# gstack-question-log — append an AskUserQuestion event to the project log.
#
# Usage:
# gstack-question-log '{"skill":"ship","question_id":"ship-test-failure-triage",\
# "question_summary":"Tests failed","options_count":3,"user_choice":"fix-now",\
# "recommended":"fix-now","session_id":"ppid"}'
#
# v1: log-only. Consumed by /plan-tune inspection and (in v2) by the
# inferred-dimension derivation pipeline.
#
# Schema (all fields validated):
# skill — skill name (kebab-case)
# question_id — either a registered id (preferred) or ad-hoc `{skill}-{slug}`
# question_summary — short one-liner of what was asked (<= 200 chars)
# category — approval | clarification | routing | cherry-pick | feedback-loop
# (optional — looked up from registry if omitted)
# door_type — one-way | two-way
# (optional — looked up from registry if omitted)
# options_count — number of options presented (positive integer)
# user_choice — key user selected (free string; registry-options preferred)
# recommended — option key the agent recommended (optional)
# followed_recommendation — bool (optional — computed if both present)
# session_id — stable session identifier
# ts — ISO 8601 timestamp (auto-injected if missing)
#
# Append-only JSONL. Dedup is at read time in gstack-question-sensitivity --read-log.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
# GSTACK_STATE_ROOT takes precedence over GSTACK_HOME (test isolation per D16).
GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}"
mkdir -p "$GSTACK_HOME/projects/$SLUG"
INPUT="$1"
# Validate and enrich from registry.
TMPERR=$(mktemp)
trap 'rm -f "$TMPERR"' EXIT
set +e
VALIDATED=$(printf '%s' "$INPUT" | bun -e "
const path = require('path');
const raw = await Bun.stdin.text();
let j;
try { j = JSON.parse(raw); } catch { process.stderr.write('gstack-question-log: invalid JSON\n'); process.exit(1); }
// Required: skill (kebab-case)
if (!j.skill || !/^[a-z0-9-]+\$/.test(j.skill)) {
process.stderr.write('gstack-question-log: invalid skill, must be kebab-case\n');
process.exit(1);
}
// Required: question_id (kebab-case, <=64 chars).
// Cathedral T5: hook-sourced events use 'hook-<10-char-hash>' which is
// kebab-case-compatible and passes the same regex.
if (!j.question_id || !/^[a-z0-9-]+\$/.test(j.question_id) || j.question_id.length > 64) {
process.stderr.write('gstack-question-log: invalid question_id, must be kebab-case <=64 chars\n');
process.exit(1);
}
// Optional: source — tags which writer produced this event.
// 'agent' (default) — preamble-driven write from inside the running agent
// 'hook' — PostToolUse hook captured it deterministically (T5)
// 'auq-other' — user picked 'Other' and typed free text (Layer 8)
// 'auto-decided' — PreToolUse enforcement hook substituted the answer (T6)
// 'codex-import-marker' / 'codex-import-pattern' — T9 backfill from Codex
const ALLOWED_SOURCES = ['agent', 'hook', 'auq-other', 'auto-decided', 'codex-import-marker', 'codex-import-pattern'];
if (j.source !== undefined) {
if (!ALLOWED_SOURCES.includes(j.source)) {
process.stderr.write('gstack-question-log: invalid source, must be one of: ' + ALLOWED_SOURCES.join(', ') + '\n');
process.exit(1);
}
} else {
j.source = 'agent';
}
// Optional: tool_use_id — Claude Code hook stdin field; used for dedup.
if (j.tool_use_id !== undefined) {
if (typeof j.tool_use_id !== 'string' || j.tool_use_id.length > 128) {
process.stderr.write('gstack-question-log: tool_use_id must be string <=128 chars\n');
process.exit(1);
}
}
// Optional: free_text — sanitize (no newlines, <=300 chars).
if (j.free_text !== undefined) {
if (typeof j.free_text !== 'string') {
process.stderr.write('gstack-question-log: 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, ' ');
}
// Required: question_summary (non-empty, <=200 chars, no newlines)
if (typeof j.question_summary !== 'string' || !j.question_summary.length) {
process.stderr.write('gstack-question-log: question_summary required\n');
process.exit(1);
}
if (j.question_summary.length > 200) {
j.question_summary = j.question_summary.slice(0, 200);
}
if (j.question_summary.includes('\n')) {
j.question_summary = j.question_summary.replace(/\n+/g, ' ');
}
// Injection defense on the summary — same patterns as learnings-log.
const INJECTION_PATTERNS = [
/ignore\s+(all\s+)?previous\s+(instructions|context|rules)/i,
/you\s+are\s+now\s+/i,
/always\s+output\s+no\s+findings/i,
/skip\s+(all\s+)?(security|review|checks)/i,
/override[:\s]/i,
/\bsystem\s*:/i,
/\bassistant\s*:/i,
/\buser\s*:/i,
/do\s+not\s+(report|flag|mention)/i,
];
for (const pat of INJECTION_PATTERNS) {
if (pat.test(j.question_summary)) {
process.stderr.write('gstack-question-log: question_summary contains suspicious instruction-like content, rejected\n');
process.exit(1);
}
}
// Registry lookup for category + door_type enrichment.
// Registry file is at \$GSTACK_ROOT/scripts/question-registry.ts, but we don't import
// TypeScript at runtime here — we pass through what was provided and fill in defaults.
// The caller (the preamble resolver) is expected to pass category+door_type from
// the registry when it knows them; for ad-hoc ids both can be omitted.
const ALLOWED_CATEGORIES = ['approval', 'clarification', 'routing', 'cherry-pick', 'feedback-loop'];
if (j.category !== undefined) {
if (!ALLOWED_CATEGORIES.includes(j.category)) {
process.stderr.write('gstack-question-log: invalid category, must be one of: ' + ALLOWED_CATEGORIES.join(', ') + '\n');
process.exit(1);
}
}
const ALLOWED_DOORS = ['one-way', 'two-way'];
if (j.door_type !== undefined) {
if (!ALLOWED_DOORS.includes(j.door_type)) {
process.stderr.write('gstack-question-log: invalid door_type, must be one-way or two-way\n');
process.exit(1);
}
}
// options_count — positive integer if present
if (j.options_count !== undefined) {
const n = Number(j.options_count);
if (!Number.isInteger(n) || n < 1 || n > 26) {
process.stderr.write('gstack-question-log: options_count must be integer in [1, 26]\n');
process.exit(1);
}
j.options_count = n;
}
// user_choice — required; <= 64 chars; single-line; no injection patterns
if (typeof j.user_choice !== 'string' || !j.user_choice.length) {
process.stderr.write('gstack-question-log: user_choice required\n');
process.exit(1);
}
if (j.user_choice.length > 64) j.user_choice = j.user_choice.slice(0, 64);
j.user_choice = j.user_choice.replace(/\n+/g, ' ');
// recommended — optional, same constraints as user_choice
if (j.recommended !== undefined) {
if (typeof j.recommended !== 'string') {
process.stderr.write('gstack-question-log: recommended must be string\n');
process.exit(1);
}
if (j.recommended.length > 64) j.recommended = j.recommended.slice(0, 64);
}
// followed_recommendation — compute if both sides present.
if (j.recommended !== undefined && j.user_choice !== undefined) {
j.followed_recommendation = j.user_choice === j.recommended;
}
// session_id — kebab-friendly; <=64 chars
if (j.session_id !== undefined) {
if (typeof j.session_id !== 'string') {
process.stderr.write('gstack-question-log: session_id must be string\n');
process.exit(1);
}
if (j.session_id.length > 64) j.session_id = j.session_id.slice(0, 64);
}
// Inject timestamp if not present.
if (!j.ts) j.ts = new Date().toISOString();
console.log(JSON.stringify(j));
" 2>"$TMPERR")
VALIDATE_RC=$?
set -e
if [ $VALIDATE_RC -ne 0 ] || [ -z "$VALIDATED" ]; then
if [ -s "$TMPERR" ]; then
cat "$TMPERR" >&2
fi
exit 1
fi
LOG_FILE="$GSTACK_HOME/projects/$SLUG/question-log.jsonl"
# Cathedral T5: composite-source dedup. If this exact (source, tool_use_id)
# was already logged within the last 100 lines, skip — protects against
# hook + agent both writing the same fire (D3 plan-tune cathedral decision).
# Lookup is bounded so the bin stays cheap on hot paths.
DEDUP_SKIP=""
if [ -f "$LOG_FILE" ]; then
DEDUP_SKIP=$(VALIDATED_JSON="$VALIDATED" LOG_FILE_PATH="$LOG_FILE" bun -e '
const fs = require("fs");
const j = JSON.parse(process.env.VALIDATED_JSON);
if (!j.tool_use_id) { console.log(""); process.exit(0); }
const want = j.source + ":" + j.tool_use_id;
const lines = fs.readFileSync(process.env.LOG_FILE_PATH, "utf-8").trim().split("\n").slice(-100);
for (const ln of lines) {
try {
const p = JSON.parse(ln);
if (p.source && p.tool_use_id && (p.source + ":" + p.tool_use_id) === want) {
console.log("dup");
process.exit(0);
}
} catch {}
}
console.log("");
' 2>/dev/null)
fi
if [ "$DEDUP_SKIP" = "dup" ]; then
echo "DEDUP: skipped (source=$(echo "$VALIDATED" | bun -e 'const j=JSON.parse(await Bun.stdin.text()); console.log(j.source);'), tool_use_id duplicate)"
exit 0
fi
echo "$VALIDATED" >> "$LOG_FILE"
# Cathedral T5: fire-and-forget --derive so inferred dimensions stay current
# without per-event latency (D17). Sub-second op; output suppressed; never
# blocks the hook caller. Skipped via GSTACK_QUESTION_LOG_NO_DERIVE=1 for
# tests that don't want the side effect.
if [ -z "${GSTACK_QUESTION_LOG_NO_DERIVE:-}" ]; then
(
nohup "$SCRIPT_DIR/gstack-developer-profile" --derive >/dev/null 2>&1 &
) >/dev/null 2>&1
fi
# NOTE: question-log.jsonl is deliberately NOT enqueued for gbrain-sync.
# Per Codex v2 review, audit/derivation data stays local alongside the
# question-preferences.json it annotates.