mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-10 12:03:59 +02:00
4dfdb7cdc2
* feat(auq): add gstack-session-kind + echo SESSION_KIND in preamble Classifies the session as spawned | headless | interactive from env markers (OPENCLAW_SESSION / GSTACK_HEADLESS / CONDUCTOR_* / CLAUDE_CODE_ENTRYPOINT / CI), defaulting to interactive. Echoed once at skill start alongside BRANCH/REPO_MODE so the AskUserQuestion-failure fallback can branch without a shell-out at failure time. Degrade-safe: empty/error => interactive. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(auq): prose fallback when AskUserQuestion fails (interactive sessions) On a genuine AUQ failure (tool absent, or present-but-erroring like Conductor's flaky MCP returning '[Tool result missing due to internal error]'): retry once, then branch on SESSION_KIND — spawned auto-chooses, headless BLOCKs, interactive renders a prose decision brief the user answers by typing a letter. The prose fallback MUST surface the triad: a clear ELI10 of the issue, a per-choice Completeness score, and a recommendation+why (one paragraph per choice). Carves out the [plan-tune auto-decide] denial as NOT a failure, and qualifies the former 'tool_use, not prose' assertions so the rule isn't self-contradicting. Tests pin the triad, the SESSION_KIND branch, the OV2 collision guard, the always-loaded guarantee, and a cross-file invariant on the auto-decide prefix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(auq): default GSTACK_HEADLESS=1 in eval/E2E runners Headless harness runs classify as headless (BLOCK on AUQ failure rather than emit a prose question no one reads). SDK runner uses ambient mutation, not the Options.env object, to avoid breaking the SDK auth pipeline. Interactive-path suites opt out by overriding the env per-run. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(auq): defensive PostToolUse error-fallback hook (OV3:B) When an AskUserQuestion call returns an error/missing result, this hook injects additionalContext reminding the model to run the prose fallback for the current SESSION_KIND. It does not render prose itself — it guarantees the reminder fires at the moment of failure instead of relying on the model recalling SESSION_KIND. Inert on success and inert if the platform never invokes PostToolUse on tool errors (unverified — could not force the Conductor MCP error in a harness; see the spike doc). The prompt-level fallback covers the case regardless. Decision logic is unit-tested deterministically; registered in setup beside the existing AUQ hooks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(auq): regenerate SKILL.md for all hosts + refresh ship goldens Regenerated from the resolver changes (gen:skill-docs --host all). Refreshes the byte-exact ship golden fixtures (claude/codex/factory). Spec prose tightened so the cross-cutting preamble addition stays under the 5% per-skill parity ceiling (investigate 4.8%) — guard unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(test): kebab testNames for section-loading E2Es to match TOUCHFILES keys The two section-loading E2E tests used display-form testNames ('/ship section-loading', '/plan-ceo-review section-loading') while every other E2E testName and their E2E_TOUCHFILES keys are kebab. The completeness gate does an exact `name in E2E_TOUCHFILES` check, so it failed (pre-existing on main); diff- based selection also couldn't match them. Align to ship-section-loading / plan-ceo-section-loading. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(test): make external-host freshness checks deterministic The parameterized host smoke + --host all freshness tests assumed an external `gen:skill-docs --host all` had run first (it never does in `bun test`), so which host reported STALE varied by sibling-test timing — flaky. Regenerate the gitignored external host dirs in a beforeAll so the --dry-run check is deterministic. It still catches non-deterministic generation (the real bug class for regenerated outputs); the tracked-claude freshness test runs earlier and is unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(parity): headroom for AUQ cross-cutting addition on carved document-release Merging main brought the carve of document-release (smaller skeleton); the AUQ prose-fallback adds ~2KB to every skill's always-loaded preamble, landing document-release at ~5.9% over the pre-carve v1.53.0.0 baseline. Add a per-carve maxSizeRatio override (CARVE_GUARDS single source of truth) and bump only this skill to 1.08. All other skills keep the strict 1.05 ceiling. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(auq): harden error-fallback hook + harness per adversarial review Codex pre-landing review found three real issues: - The PostToolUse fallback hook shared source 'plan-tune-cathedral' with the question-log hook (same event+matcher); gstack-settings-hook replaces the entry, so it would have clobbered plan-tune capture. Give it its own 'auq-error-fallback' source (separate entry, both run); ALREADY_INSTALLED now requires both sources. - isErrorResponse triggered on any string containing 'internal error'/'is_error', so a real answer or a {"is_error": false} payload could fire the fallback after a successful question. Narrow it to the missing-result sentinel + boolean is_error. - The SDK runner mutated process.env.GSTACK_HEADLESS process-wide (leaked headless into later tests). Removed; GSTACK_HEADLESS=1 now lives in the eval package.json scripts, scoped to the invocation and inherited by the SDK child. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v1.57.2.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
126 lines
5.1 KiB
TypeScript
126 lines
5.1 KiB
TypeScript
import type { ProviderAdapter, RunOpts, RunResult, AvailabilityCheck } from './types';
|
|
import { estimateCostUsd } from '../pricing';
|
|
import { execFileSync } from 'child_process';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
import { resolveClaudeCommand } from '../../../browse/src/claude-bin';
|
|
|
|
/**
|
|
* Claude adapter — wraps the `claude` CLI via claude -p.
|
|
*
|
|
* For brevity and to avoid duplicating the full stream-json parser, this adapter
|
|
* uses claude CLI in non-interactive mode (--print) with the simpler JSON output
|
|
* format. If richer event-level metrics are needed (per-tool timing etc.),
|
|
* swap to session-runner's full stream-json parser.
|
|
*/
|
|
export class ClaudeAdapter implements ProviderAdapter {
|
|
readonly name = 'claude';
|
|
readonly family = 'claude' as const;
|
|
|
|
async available(): Promise<AvailabilityCheck> {
|
|
// Binary on PATH (or GSTACK_CLAUDE_BIN override). Routes through the shared
|
|
// resolver so Windows + override paths behave the same as production sites.
|
|
const resolved = resolveClaudeCommand();
|
|
if (!resolved) {
|
|
return { ok: false, reason: 'claude CLI not found on PATH. Install from https://claude.ai/download or npm i -g @anthropic-ai/claude-code (or set GSTACK_CLAUDE_BIN)' };
|
|
}
|
|
// Auth sniff: ~/.claude/.credentials.json OR ANTHROPIC_API_KEY
|
|
const credsPath = path.join(os.homedir(), '.claude', '.credentials.json');
|
|
const hasCreds = fs.existsSync(credsPath);
|
|
const hasKey = !!process.env.ANTHROPIC_API_KEY;
|
|
if (!hasCreds && !hasKey) {
|
|
return { ok: false, reason: 'No Claude auth found. Log in via `claude` interactive session, or export ANTHROPIC_API_KEY.' };
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
async run(opts: RunOpts): Promise<RunResult> {
|
|
const start = Date.now();
|
|
const resolved = resolveClaudeCommand();
|
|
if (!resolved) {
|
|
throw new Error('claude CLI not resolvable (set GSTACK_CLAUDE_BIN or install)');
|
|
}
|
|
const args = [...resolved.argsPrefix, '-p', '--output-format', 'json'];
|
|
if (opts.model) args.push('--model', opts.model);
|
|
if (opts.extraArgs) args.push(...opts.extraArgs);
|
|
|
|
try {
|
|
const out = execFileSync(resolved.command, args, {
|
|
input: opts.prompt,
|
|
cwd: opts.workdir,
|
|
timeout: opts.timeoutMs,
|
|
encoding: 'utf-8',
|
|
maxBuffer: 32 * 1024 * 1024,
|
|
// Default GSTACK_HEADLESS=1 so a benchmark run classifies as headless (an
|
|
// AskUserQuestion failure BLOCKs rather than emitting unanswerable prose).
|
|
env: { ...process.env, GSTACK_HEADLESS: '1' },
|
|
});
|
|
const parsed = this.parseOutput(out);
|
|
return {
|
|
output: parsed.output,
|
|
tokens: parsed.tokens,
|
|
durationMs: Date.now() - start,
|
|
toolCalls: parsed.toolCalls,
|
|
modelUsed: parsed.modelUsed || opts.model || 'claude-opus-4-7',
|
|
};
|
|
} catch (err: unknown) {
|
|
const durationMs = Date.now() - start;
|
|
const e = err as { code?: string; stderr?: Buffer; signal?: string; message?: string };
|
|
const stderr = e.stderr?.toString() ?? '';
|
|
if (e.signal === 'SIGTERM' || e.code === 'ETIMEDOUT') {
|
|
return this.emptyResult(durationMs, { code: 'timeout', reason: `exceeded ${opts.timeoutMs}ms` }, opts.model);
|
|
}
|
|
if (/unauthorized|auth|login/i.test(stderr)) {
|
|
return this.emptyResult(durationMs, { code: 'auth', reason: stderr.slice(0, 400) }, opts.model);
|
|
}
|
|
if (/rate[- ]?limit|429/i.test(stderr)) {
|
|
return this.emptyResult(durationMs, { code: 'rate_limit', reason: stderr.slice(0, 400) }, opts.model);
|
|
}
|
|
return this.emptyResult(durationMs, { code: 'unknown', reason: (e.message ?? stderr ?? 'unknown').slice(0, 400) }, opts.model);
|
|
}
|
|
}
|
|
|
|
estimateCost(tokens: { input: number; output: number; cached?: number }, model?: string): number {
|
|
return estimateCostUsd(tokens, model ?? 'claude-opus-4-7');
|
|
}
|
|
|
|
/**
|
|
* Parse claude -p --output-format json output. Shape (as of 2026-04):
|
|
* { type: "result", result: "<assistant text>", usage: { input_tokens, output_tokens, ... },
|
|
* num_turns, session_id, ... }
|
|
* Older formats may differ — adapter is best-effort.
|
|
*/
|
|
private parseOutput(raw: string): { output: string; tokens: { input: number; output: number; cached?: number }; toolCalls: number; modelUsed?: string } {
|
|
try {
|
|
const obj = JSON.parse(raw);
|
|
const result = typeof obj.result === 'string' ? obj.result : String(obj.result ?? '');
|
|
const u = obj.usage ?? {};
|
|
return {
|
|
output: result,
|
|
tokens: {
|
|
input: u.input_tokens ?? 0,
|
|
output: u.output_tokens ?? 0,
|
|
cached: u.cache_read_input_tokens,
|
|
},
|
|
toolCalls: obj.num_turns ?? 0,
|
|
modelUsed: obj.model,
|
|
};
|
|
} catch {
|
|
// Non-JSON output: treat as plain text.
|
|
return { output: raw, tokens: { input: 0, output: 0 }, toolCalls: 0 };
|
|
}
|
|
}
|
|
|
|
private emptyResult(durationMs: number, error: RunResult['error'], model?: string): RunResult {
|
|
return {
|
|
output: '',
|
|
tokens: { input: 0, output: 0 },
|
|
durationMs,
|
|
toolCalls: 0,
|
|
modelUsed: model ?? 'claude-opus-4-7',
|
|
error,
|
|
};
|
|
}
|
|
}
|