mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-13 00:19:03 +02:00
Merge remote-tracking branch 'origin/main' into garrytan/gbrain-code-smell-audit
# Conflicts: # CHANGELOG.md # browse/test/dual-listener.test.ts # browse/test/fixtures/security-bench-haiku-responses.json # browse/test/sidebar-tabs.test.ts # browse/test/sidebar-ux.test.ts # browse/test/terminal-agent.test.ts # claude/SKILL.md.tmpl # scripts/gen-skill-docs.ts # scripts/proactive-suggestions.json # spec/SKILL.md # test/gen-skill-docs.test.ts # test/host-config.test.ts
This commit is contained in:
+1
-1
@@ -14,7 +14,7 @@ const claude = defineHost({
|
||||
|
||||
generation: {
|
||||
generateMetadata: false,
|
||||
skipSkills: [], // overrides the default ['codex'] — the /codex skill IS a Claude skill (wrapper around codex exec)
|
||||
skipSkills: ['claude'], // the /claude outside-voice skill is for non-Claude hosts; /codex stays (it IS a Claude skill wrapping codex exec)
|
||||
},
|
||||
|
||||
pathRewrites: [], // Claude is the primary host — no rewrites needed
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { runBin } from './spawn-bin';
|
||||
|
||||
interface HookStdin {
|
||||
tool_name?: string;
|
||||
@@ -126,9 +126,7 @@ export function isErrorResponse(response: unknown): boolean {
|
||||
* echoes). Falls back to 'interactive' (degrade-safe) on any failure. */
|
||||
export function sessionKind(cwd?: string): 'spawned' | 'headless' | 'interactive' {
|
||||
try {
|
||||
const here = path.dirname(new URL(import.meta.url).pathname);
|
||||
const bin = path.resolve(here, '..', '..', '..', 'bin', 'gstack-session-kind');
|
||||
const res = spawnSync(bin, [], {
|
||||
const res = runBin('gstack-session-kind', [], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 3000,
|
||||
cwd: cwd && fs.existsSync(cwd) ? cwd : undefined,
|
||||
|
||||
@@ -36,7 +36,7 @@ import * as crypto from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { runBin } from './spawn-bin';
|
||||
|
||||
interface HookStdin {
|
||||
session_id?: string;
|
||||
@@ -156,21 +156,63 @@ function extractRecommended(questionText: string, opts: string[]): string | unde
|
||||
* AUQ tool_response shape varies by Claude Code variant (native vs MCP),
|
||||
* and the hook stdin docs don't pin a single canonical shape. We handle
|
||||
* the common cases gracefully.
|
||||
*
|
||||
* Shape D is the current native AskUserQuestion result:
|
||||
* { answers: { "<question text>": "<answer>" },
|
||||
* annotations?: { "<question text>": { notes?, preview? } } }
|
||||
* The map is keyed by the question text exactly as passed in tool_input,
|
||||
* so extraction needs the questions themselves, not just a count.
|
||||
*/
|
||||
function extractUserChoices(
|
||||
response: unknown,
|
||||
questionCount: number,
|
||||
questions: Array<{ question?: string; options?: Array<string | { label?: string; description?: string }> }>,
|
||||
diag?: (msg: string) => void,
|
||||
): Array<{ choice: string; free_text?: string }> {
|
||||
const questionCount = questions.length;
|
||||
const out: Array<{ choice: string; free_text?: string }> = [];
|
||||
if (!response) {
|
||||
diag?.(`answer-extract: empty tool_response (typeof=${typeof response})`);
|
||||
for (let i = 0; i < questionCount; i++) out.push({ choice: '__unknown__' });
|
||||
return out;
|
||||
}
|
||||
// Shape A: { answers: [{option_label, free_text?}] }
|
||||
// Shape B: { questions: [{user_answer}] }
|
||||
// Shape C: { content: [...] } or array.
|
||||
// We probe lazily.
|
||||
const rec = response as Record<string, unknown>;
|
||||
// Shape D: { answers: {questionText: answer}, annotations?: {questionText: {notes}} }
|
||||
if (rec.answers && typeof rec.answers === 'object' && !Array.isArray(rec.answers)) {
|
||||
const answers = rec.answers as Record<string, unknown>;
|
||||
const annotations =
|
||||
rec.annotations && typeof rec.annotations === 'object' && !Array.isArray(rec.annotations)
|
||||
? (rec.annotations as Record<string, Record<string, unknown>>)
|
||||
: {};
|
||||
const keys = Object.keys(answers);
|
||||
const norm = (s: string) => s.replace(/\s+/g, ' ').trim().toLowerCase();
|
||||
for (const q of questions) {
|
||||
const qText = q.question || '';
|
||||
let key: string | undefined = Object.prototype.hasOwnProperty.call(answers, qText)
|
||||
? qText
|
||||
: keys.find((k) => norm(k) === norm(qText));
|
||||
// Single question, single answer: pair them even if the key drifted.
|
||||
if (key === undefined && keys.length === 1 && questionCount === 1) key = keys[0];
|
||||
if (key === undefined) {
|
||||
diag?.(`answer-extract: no answers key matched question "${qText.slice(0, 60)}"`);
|
||||
out.push({ choice: '__unknown__' });
|
||||
continue;
|
||||
}
|
||||
const v = answers[key];
|
||||
const rawChoice = Array.isArray(v) ? v.map(String).join(', ') : String(v ?? '__unknown__');
|
||||
// The bin compares user_choice === recommended, and recommended is
|
||||
// stored with the "(recommended)" suffix stripped — strip it here too.
|
||||
const choice = rawChoice.replace(RECOMMENDED_LABEL_RE, '').trim() || '__unknown__';
|
||||
const labels = optionLabels(q.options || []).map((l) =>
|
||||
l.replace(RECOMMENDED_LABEL_RE, '').trim().toLowerCase(),
|
||||
);
|
||||
const notes = annotations[key]?.notes;
|
||||
const isFreeText = !Array.isArray(v) && labels.length > 0 && !labels.includes(choice.toLowerCase());
|
||||
const freeText = notes !== undefined ? String(notes) : isFreeText ? rawChoice : undefined;
|
||||
out.push(freeText !== undefined ? { choice, free_text: freeText } : { choice });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// Shape A: { answers: [{option_label, free_text?}] }
|
||||
if (Array.isArray(rec.answers)) {
|
||||
for (const a of rec.answers as Array<Record<string, unknown>>) {
|
||||
const choice = (a.option_label || a.label || a.choice || a.answer || '__unknown__') as string;
|
||||
@@ -180,6 +222,7 @@ function extractUserChoices(
|
||||
while (out.length < questionCount) out.push({ choice: '__unknown__' });
|
||||
return out;
|
||||
}
|
||||
// Shape B: { questions: [{user_answer}] }
|
||||
if (Array.isArray(rec.questions)) {
|
||||
for (const q of rec.questions as Array<Record<string, unknown>>) {
|
||||
const choice = (q.user_answer || q.answer || q.choice || '__unknown__') as string;
|
||||
@@ -188,9 +231,11 @@ function extractUserChoices(
|
||||
while (out.length < questionCount) out.push({ choice: '__unknown__' });
|
||||
return out;
|
||||
}
|
||||
// Fall back: stringify and log first 100 chars to help future debugging.
|
||||
// Unrecognized shape: log it for postmortem (never embed it in the record —
|
||||
// that poisons user_choice for every downstream metric).
|
||||
diag?.(`answer-extract: unrecognized tool_response shape: ${JSON.stringify(response).slice(0, 300)}`);
|
||||
for (let i = 0; i < questionCount; i++) {
|
||||
out.push({ choice: `__response-shape-unknown:${JSON.stringify(response).slice(0, 80)}__` });
|
||||
out.push({ choice: '__unknown__' });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -205,12 +250,7 @@ function detectSkill(cwd: string | undefined): string {
|
||||
}
|
||||
|
||||
function spawnLog(payload: Record<string, unknown>, cwd?: string): void {
|
||||
// Locate the bin relative to this script's directory.
|
||||
const here = path.dirname(new URL(import.meta.url).pathname);
|
||||
// hosts/claude/hooks/ -> ../../../bin/
|
||||
const repoRoot = path.resolve(here, '..', '..', '..');
|
||||
const bin = path.join(repoRoot, 'bin', 'gstack-question-log');
|
||||
const res = spawnSync(bin, [JSON.stringify(payload)], {
|
||||
const res = runBin('gstack-question-log', [JSON.stringify(payload)], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 3000,
|
||||
@@ -251,7 +291,9 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
const skill = detectSkill(stdin.cwd);
|
||||
const choices = extractUserChoices(stdin.tool_response, questions.length);
|
||||
const choices = extractUserChoices(stdin.tool_response, questions, (msg) =>
|
||||
logHookError(`${msg} (tool_use_id=${stdin.tool_use_id || 'n/a'})`),
|
||||
);
|
||||
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
const q = questions[i];
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { runBin, repoRoot } from './spawn-bin';
|
||||
import { isConductor } from '../../../lib/is-conductor';
|
||||
import { classifyQuestion } from '../../../scripts/one-way-doors';
|
||||
|
||||
@@ -240,9 +240,7 @@ function loadRegistry(): Record<string, RegistryEntry> {
|
||||
registryCache = {};
|
||||
try {
|
||||
// Hook lives at hosts/claude/hooks/; registry at scripts/question-registry.ts
|
||||
const here = path.dirname(new URL(import.meta.url).pathname);
|
||||
const repoRoot = path.resolve(here, '..', '..', '..');
|
||||
const regPath = path.join(repoRoot, 'scripts', 'question-registry.ts');
|
||||
const regPath = path.join(repoRoot(), 'scripts', 'question-registry.ts');
|
||||
if (!fs.existsSync(regPath)) return registryCache;
|
||||
const src = fs.readFileSync(regPath, 'utf-8');
|
||||
// Cheap regex extraction so the hook doesn't need to import the TS file
|
||||
@@ -334,9 +332,6 @@ function logAutoDecided(
|
||||
cwd: string | undefined,
|
||||
): void {
|
||||
try {
|
||||
const here = path.dirname(new URL(import.meta.url).pathname);
|
||||
const repoRoot = path.resolve(here, '..', '..', '..');
|
||||
const bin = path.join(repoRoot, 'bin', 'gstack-question-log');
|
||||
const payload: Record<string, unknown> = {
|
||||
skill: 'unknown',
|
||||
question_id: questionId,
|
||||
@@ -348,7 +343,7 @@ function logAutoDecided(
|
||||
session_id: sessionId?.slice(0, 64),
|
||||
tool_use_id: toolUseId?.slice(0, 128),
|
||||
};
|
||||
spawnSync(bin, [JSON.stringify(payload)], {
|
||||
runBin('gstack-question-log', [JSON.stringify(payload)], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 3000,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Windows-safe resolution + spawn for gstack's bash bins. Two Windows-only
|
||||
* bugs made every hook subprocess a silent no-op; both are fixed here so all
|
||||
* call sites are covered at once.
|
||||
*
|
||||
* 1. `new URL(import.meta.url).pathname` yields `/C:/Users/...`; path.resolve
|
||||
* then rebases it onto the drive root as `C:\C:\Users\...`. fileURLToPath
|
||||
* is the correct conversion. (ENOENT before the bin ever ran.)
|
||||
* 2. `bin/gstack-*` are extensionless bash scripts. Windows has no shebang
|
||||
* support, so they must be handed to bash explicitly.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { spawnSync, type SpawnSyncOptions } from 'child_process';
|
||||
|
||||
// Forward slashes on purpose: Bun's spawnSync on Windows returns ENOENT for a
|
||||
// backslash exe path containing spaces.
|
||||
const GIT_BASH = 'C:/Program Files/Git/bin/bash.exe';
|
||||
|
||||
/** bash Windows itself can execute — env override, Git Bash, then PATH. */
|
||||
function bashExe(): string {
|
||||
return process.env.GSTACK_BASH || (fs.existsSync(GIT_BASH) ? GIT_BASH : 'bash');
|
||||
}
|
||||
|
||||
/** gstack install root. This file lives at hosts/claude/hooks/. */
|
||||
export function repoRoot(): string {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
return path.resolve(here, '..', '..', '..');
|
||||
}
|
||||
|
||||
/** Absolute path to a `bin/` script. */
|
||||
export function binPath(name: string): string {
|
||||
return path.join(repoRoot(), 'bin', name);
|
||||
}
|
||||
|
||||
/** Resolve `name` under bin/ and run it, via bash on Windows. */
|
||||
export function runBin(name: string, args: string[], opts: SpawnSyncOptions) {
|
||||
const bin = binPath(name);
|
||||
return process.platform === 'win32'
|
||||
? spawnSync(bashExe(), [bin, ...args], opts)
|
||||
: spawnSync(bin, args, opts);
|
||||
}
|
||||
@@ -32,6 +32,7 @@ const codex = defineHost({
|
||||
{ from: '.claude/skills/gstack', to: '.agents/skills/gstack' },
|
||||
{ from: '.claude/skills/review', to: '.agents/skills/gstack/review' },
|
||||
{ from: '.claude/skills', to: '.agents/skills' },
|
||||
{ from: 'CLAUDE.md', to: 'AGENTS.md' },
|
||||
],
|
||||
|
||||
// The cross-model resolvers all shell out to Codex — Codex can't invoke itself.
|
||||
|
||||
Reference in New Issue
Block a user