chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import type { HostConfig } from '../scripts/host-config';
|
||||
|
||||
const claude: HostConfig = {
|
||||
name: 'claude',
|
||||
displayName: 'Claude Code',
|
||||
cliCommand: 'claude',
|
||||
cliAliases: [],
|
||||
|
||||
globalRoot: '.claude/skills/gstack',
|
||||
localSkillRoot: '.claude/skills/gstack',
|
||||
hostSubdir: '.claude',
|
||||
usesEnvVars: false,
|
||||
|
||||
frontmatter: {
|
||||
mode: 'denylist',
|
||||
stripFields: ['sensitive', 'voice-triggers'],
|
||||
descriptionLimit: null,
|
||||
},
|
||||
|
||||
generation: {
|
||||
generateMetadata: false,
|
||||
skipSkills: ['claude'], // Claude outside-voice skill is for non-Claude hosts
|
||||
},
|
||||
|
||||
pathRewrites: [], // Claude is the primary host — no rewrites needed
|
||||
toolRewrites: {},
|
||||
suppressedResolvers: ['GBRAIN_CONTEXT_LOAD', 'GBRAIN_SAVE_RESULTS'],
|
||||
|
||||
runtimeRoot: {
|
||||
globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'],
|
||||
globalFiles: {
|
||||
'review': ['checklist.md', 'TODOS-format.md'],
|
||||
},
|
||||
},
|
||||
|
||||
install: {
|
||||
prefixable: true,
|
||||
linkingStrategy: 'real-dir-symlink',
|
||||
},
|
||||
|
||||
coAuthorTrailer: 'Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>',
|
||||
learningsMode: 'full',
|
||||
};
|
||||
|
||||
export default claude;
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bash shim — Claude Code hooks run `command` strings via /bin/sh, so this
|
||||
# wrapper makes the TypeScript hook executable via bun. Settings.json
|
||||
# references this file directly.
|
||||
set -e
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec bun "$HERE/auq-error-fallback-hook.ts"
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* PostToolUse hook for AskUserQuestion — runtime reliability layer for the
|
||||
* AUQ-failure prose fallback (OV3:B).
|
||||
*
|
||||
* When an AskUserQuestion call comes back as an ERROR / missing result (the
|
||||
* Conductor MCP bug returns `[Tool result missing due to internal error]`), this
|
||||
* hook injects `additionalContext` reminding the model of the failure-fallback
|
||||
* rule, tailored to the session kind. It does NOT render the prose itself — the
|
||||
* model still emits it. The hook only guarantees the reminder fires at the moment
|
||||
* of failure, instead of relying on the model noticing the error result and
|
||||
* recalling the echoed SESSION_KIND.
|
||||
*
|
||||
* DEFENSIVE / INERT-IF-UNSUPPORTED: it is unverified whether Claude Code invokes
|
||||
* PostToolUse hooks when an MCP tool returns a transport/missing-result error (we
|
||||
* could not force that Conductor-internal failure in a harness — see
|
||||
* docs/spikes/claude-code-hook-mutation.md §"PostToolUse on tool error"). If the
|
||||
* platform does NOT fire the hook on that path, this is simply never invoked — no
|
||||
* harm; the prompt-level fallback in generate-ask-user-format.ts still covers it.
|
||||
* On a SUCCESSFUL AskUserQuestion (a real answer), the hook defers (no output).
|
||||
*
|
||||
* Triggered by ~/.claude/settings.json (registered by `setup` next to
|
||||
* question-log-hook):
|
||||
* PostToolUse matcher "(AskUserQuestion|mcp__.*__AskUserQuestion)"
|
||||
*
|
||||
* Invariants:
|
||||
* - Always exits 0. A failing hook MUST NOT block the user's session.
|
||||
* - Never triggers on a successful answer (would corrupt a normal AUQ).
|
||||
* - Errors land in ~/.gstack/hook-errors.log.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
interface HookStdin {
|
||||
tool_name?: string;
|
||||
tool_response?: unknown;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
function stateRoot(): string {
|
||||
return (
|
||||
process.env.GSTACK_STATE_ROOT ||
|
||||
process.env.GSTACK_HOME ||
|
||||
path.join(os.homedir(), '.gstack')
|
||||
);
|
||||
}
|
||||
|
||||
function logHookError(msg: string): void {
|
||||
try {
|
||||
const sr = stateRoot();
|
||||
fs.mkdirSync(sr, { recursive: true });
|
||||
fs.appendFileSync(
|
||||
path.join(sr, 'hook-errors.log'),
|
||||
`${new Date().toISOString()} auq-error-fallback-hook: ${msg}\n`,
|
||||
);
|
||||
} catch {
|
||||
// last-resort swallow
|
||||
}
|
||||
}
|
||||
|
||||
function readStdin(): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
let buf = '';
|
||||
process.stdin.setEncoding('utf-8');
|
||||
process.stdin.on('data', (chunk) => (buf += chunk));
|
||||
process.stdin.on('end', () => resolve(buf));
|
||||
process.stdin.on('error', () => resolve(buf));
|
||||
setTimeout(() => resolve(buf), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
/** No-op output — let the tool result stand untouched. */
|
||||
function defer(): void {
|
||||
process.stdout.write(
|
||||
JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolUse' } }),
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
function inject(additionalContext: string): void {
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext },
|
||||
}),
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether the tool_response is an ERROR / missing result rather than a
|
||||
* real answer. Conservative: only flag clear failure shapes, so a successful
|
||||
* AskUserQuestion (which carries the user's choice) is never misread as failure.
|
||||
*/
|
||||
export function isErrorResponse(response: unknown): boolean {
|
||||
if (response === null || response === undefined) return true;
|
||||
if (typeof response === 'string') {
|
||||
const s = response.trim();
|
||||
if (s === '') return true;
|
||||
// Match ONLY the specific missing-result sentinel phrase, not any string that
|
||||
// merely contains "error" — a real answer like "Investigate the internal error"
|
||||
// must NOT trigger the fallback. (Codex review finding.)
|
||||
return /tool result missing/i.test(s);
|
||||
}
|
||||
if (typeof response === 'object') {
|
||||
const rec = response as Record<string, unknown>;
|
||||
// Structured flag must be the boolean true — not the substring "is_error" inside
|
||||
// a serialized success payload like '{"is_error": false}'.
|
||||
if (rec.is_error === true || rec.isError === true) return true;
|
||||
if (typeof rec.error === 'string' && rec.error.trim() !== '') return true;
|
||||
// Some hosts wrap the payload as { content: "..." } or { content: [{text}] }.
|
||||
const content = rec.content;
|
||||
if (typeof content === 'string') return /tool result missing/i.test(content);
|
||||
if (Array.isArray(content)) {
|
||||
const text = content
|
||||
.map((c) => (typeof c === 'string' ? c : (c as Record<string, unknown>)?.text ?? ''))
|
||||
.join(' ');
|
||||
return /tool result missing/i.test(text);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Resolve SESSION_KIND via the shared helper (same classification the preamble
|
||||
* 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, [], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 3000,
|
||||
cwd: cwd && fs.existsSync(cwd) ? cwd : undefined,
|
||||
});
|
||||
const out = (res.stdout || '').trim();
|
||||
if (out === 'spawned' || out === 'headless' || out === 'interactive') return out;
|
||||
} catch (e) {
|
||||
logHookError(`sessionKind failed: ${(e as Error).message}`);
|
||||
}
|
||||
return 'interactive';
|
||||
}
|
||||
|
||||
/** The directive injected per session kind. Exported for unit testing. */
|
||||
export function directiveFor(kind: 'spawned' | 'headless' | 'interactive'): string {
|
||||
const lead =
|
||||
'The AskUserQuestion call did not return a usable answer (error / missing result). ' +
|
||||
'Per the AskUserQuestion failure-fallback rule: ';
|
||||
switch (kind) {
|
||||
case 'spawned':
|
||||
return (
|
||||
lead +
|
||||
'SESSION_KIND=spawned — auto-choose the recommended option per the Spawned session block. ' +
|
||||
'Do not emit prose, do not BLOCK.'
|
||||
);
|
||||
case 'headless':
|
||||
return (
|
||||
lead +
|
||||
'SESSION_KIND=headless — report `BLOCKED — AskUserQuestion unavailable` and stop; no human can answer.'
|
||||
);
|
||||
case 'interactive':
|
||||
default:
|
||||
return (
|
||||
lead +
|
||||
'SESSION_KIND=interactive — render the decision as a PROSE message now: a clear ELI10 of the issue, ' +
|
||||
'then a Recommendation line, then ONE paragraph per choice carrying its `(recommended)` marker, its ' +
|
||||
'`Completeness: X/10`, and 2-4 sentences of reasoning. Tell the user to reply with a letter, then STOP. ' +
|
||||
'(Retry the call once first only if no answer could have surfaced.)'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const raw = await readStdin();
|
||||
if (!raw.trim()) return defer();
|
||||
|
||||
let stdin: HookStdin;
|
||||
try {
|
||||
stdin = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
logHookError(`stdin parse failed: ${(e as Error).message}`);
|
||||
return defer();
|
||||
}
|
||||
|
||||
const toolName = stdin.tool_name || '';
|
||||
if (toolName !== 'AskUserQuestion' && !/^mcp__.+__AskUserQuestion$/.test(toolName)) {
|
||||
return defer();
|
||||
}
|
||||
|
||||
if (!isErrorResponse(stdin.tool_response)) return defer();
|
||||
|
||||
inject(directiveFor(sessionKind(stdin.cwd)));
|
||||
}
|
||||
|
||||
// Only run the stdin→stdout pipeline when executed as a hook, not when imported
|
||||
// by the unit test (which exercises the exported pure functions).
|
||||
if (import.meta.main) {
|
||||
main().catch((e) => {
|
||||
logHookError(`main crash: ${(e as Error).message}`);
|
||||
defer();
|
||||
});
|
||||
}
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bash shim — Claude Code hooks run `command` strings via /bin/sh, so this
|
||||
# wrapper makes the TypeScript hook executable via bun. Settings.json
|
||||
# references this file directly.
|
||||
set -e
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec bun "$HERE/question-log-hook.ts"
|
||||
@@ -0,0 +1,289 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* PostToolUse hook for AskUserQuestion (Claude Code, plan-tune cathedral T5).
|
||||
*
|
||||
* Reads hook stdin JSON, extracts every AUQ question + user choice from the
|
||||
* tool_input/tool_response, and writes them via gstack-question-log so the
|
||||
* substrate captures fires deterministically — no agent compliance required.
|
||||
*
|
||||
* Triggered by ~/.claude/settings.json:
|
||||
* {
|
||||
* "hooks": {
|
||||
* "PostToolUse": [
|
||||
* {
|
||||
* "matcher": "(AskUserQuestion|mcp__.*__AskUserQuestion)",
|
||||
* "hooks": [
|
||||
* { "type": "command",
|
||||
* "command": "$CLAUDE_PROJECT_DIR/.claude/skills/gstack/hosts/claude/hooks/question-log-hook",
|
||||
* "timeout": 5 }
|
||||
* ]
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Invariants:
|
||||
* - Always exits 0. A failing hook MUST NOT block the user's session.
|
||||
* Errors land in ~/.gstack/hook-errors.log for postmortem.
|
||||
* - Spawns gstack-question-log as a subprocess; that bin handles
|
||||
* validation, dedup (source+tool_use_id), async derive.
|
||||
* - Marker-first question_id (`<gstack-qid:foo-bar>`), hash fallback
|
||||
* (D18 progressive markers).
|
||||
*
|
||||
* See docs/spikes/claude-code-hook-mutation.md for the protocol contract.
|
||||
*/
|
||||
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';
|
||||
|
||||
interface HookStdin {
|
||||
session_id?: string;
|
||||
hook_event_name?: string;
|
||||
tool_name?: string;
|
||||
tool_use_id?: string;
|
||||
tool_input?: {
|
||||
questions?: Array<{
|
||||
question?: string;
|
||||
options?: Array<string | { label?: string; description?: string }>;
|
||||
multiSelect?: boolean;
|
||||
}>;
|
||||
};
|
||||
tool_response?: unknown;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
interface ExtractedQuestion {
|
||||
question_id: string;
|
||||
question_summary: string;
|
||||
options_count: number;
|
||||
user_choice: string;
|
||||
recommended?: string;
|
||||
free_text?: string;
|
||||
category?: string;
|
||||
door_type?: string;
|
||||
}
|
||||
|
||||
const MARKER_RE = /<gstack-qid:([a-z0-9-]{1,64})>/i;
|
||||
const RECOMMENDED_LABEL_RE = /\(recommended\)\s*$/i;
|
||||
|
||||
function logHookError(msg: string): void {
|
||||
try {
|
||||
const stateRoot =
|
||||
process.env.GSTACK_STATE_ROOT ||
|
||||
process.env.GSTACK_HOME ||
|
||||
path.join(os.homedir(), '.gstack');
|
||||
fs.mkdirSync(stateRoot, { recursive: true });
|
||||
fs.appendFileSync(
|
||||
path.join(stateRoot, 'hook-errors.log'),
|
||||
`${new Date().toISOString()} question-log-hook: ${msg}\n`,
|
||||
);
|
||||
} catch {
|
||||
// Last-resort: swallow. Hook must not block.
|
||||
}
|
||||
}
|
||||
|
||||
function readStdin(): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
let buf = '';
|
||||
process.stdin.setEncoding('utf-8');
|
||||
process.stdin.on('data', (chunk) => (buf += chunk));
|
||||
process.stdin.on('end', () => resolve(buf));
|
||||
process.stdin.on('error', () => resolve(buf));
|
||||
// Hard cutoff so we don't hang the user's session waiting for stdin.
|
||||
setTimeout(() => resolve(buf), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function hashQuestionId(skill: string, question: string, options: string[]): string {
|
||||
const sorted = [...options].sort().join('|');
|
||||
const h = crypto
|
||||
.createHash('sha1')
|
||||
.update(`${skill}::${question}::${sorted}`)
|
||||
.digest('hex');
|
||||
return `hook-${h.slice(0, 10)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marker-first id extraction. Returns the marker id (stripped of the
|
||||
* <gstack-qid:...> wrapper) when present, else a hash-based hook- id.
|
||||
* Per D18 progressive markers — hash ids are observed-only, never used
|
||||
* as preference keys.
|
||||
*/
|
||||
function extractQuestionId(
|
||||
skill: string,
|
||||
questionText: string,
|
||||
options: string[],
|
||||
): { id: string; marker_present: boolean; stripped_question: string } {
|
||||
const match = questionText.match(MARKER_RE);
|
||||
if (match) {
|
||||
return {
|
||||
id: match[1],
|
||||
marker_present: true,
|
||||
stripped_question: questionText.replace(MARKER_RE, '').trim(),
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: hashQuestionId(skill, questionText, options),
|
||||
marker_present: false,
|
||||
stripped_question: questionText,
|
||||
};
|
||||
}
|
||||
|
||||
function optionLabels(opts: Array<string | { label?: string; description?: string }>): string[] {
|
||||
return opts.map((o) => (typeof o === 'string' ? o : o.label || o.description || ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse "(recommended)" label-first per D2; fall back to "Recommendation: X"
|
||||
* prose match; refuse (return undefined) if ambiguous.
|
||||
*/
|
||||
function extractRecommended(questionText: string, opts: string[]): string | undefined {
|
||||
const labelMatches = opts.filter((o) => RECOMMENDED_LABEL_RE.test(o));
|
||||
if (labelMatches.length === 1) return labelMatches[0].replace(RECOMMENDED_LABEL_RE, '').trim();
|
||||
if (labelMatches.length > 1) return undefined; // ambiguous
|
||||
|
||||
const m = questionText.match(/Recommendation:\s*([^\n]+)/i);
|
||||
if (!m) return undefined;
|
||||
const recPhrase = m[1].trim();
|
||||
const matchByPrefix = opts.find((o) => o.toLowerCase().startsWith(recPhrase.toLowerCase().slice(0, 12)));
|
||||
return matchByPrefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort extraction of which option the user picked per question.
|
||||
* 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.
|
||||
*/
|
||||
function extractUserChoices(
|
||||
response: unknown,
|
||||
questionCount: number,
|
||||
): Array<{ choice: string; free_text?: string }> {
|
||||
const out: Array<{ choice: string; free_text?: string }> = [];
|
||||
if (!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>;
|
||||
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;
|
||||
const freeText = (a.free_text || a.other_text) as string | undefined;
|
||||
out.push(freeText ? { choice, free_text: freeText } : { choice });
|
||||
}
|
||||
while (out.length < questionCount) out.push({ choice: '__unknown__' });
|
||||
return out;
|
||||
}
|
||||
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;
|
||||
out.push({ choice });
|
||||
}
|
||||
while (out.length < questionCount) out.push({ choice: '__unknown__' });
|
||||
return out;
|
||||
}
|
||||
// Fall back: stringify and log first 100 chars to help future debugging.
|
||||
for (let i = 0; i < questionCount; i++) {
|
||||
out.push({ choice: `__response-shape-unknown:${JSON.stringify(response).slice(0, 80)}__` });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function detectSkill(cwd: string | undefined): string {
|
||||
// Best-effort: cwd often contains the project slug but rarely the running
|
||||
// skill. Without a session-state mechanism, leave as 'unknown' — the
|
||||
// skill marker (<gstack-skill:NAME>) embedded in question text per
|
||||
// future plan-tune work is the durable path.
|
||||
void cwd;
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
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)], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 3000,
|
||||
// Run from the originating tool call's cwd so gstack-slug resolves to
|
||||
// the project the user is actually in, not the hook script's location.
|
||||
cwd: cwd && fs.existsSync(cwd) ? cwd : undefined,
|
||||
});
|
||||
if (res.status !== 0) {
|
||||
logHookError(`gstack-question-log exited ${res.status}: ${res.stderr || res.stdout}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const raw = await readStdin();
|
||||
if (!raw.trim()) {
|
||||
process.exit(0);
|
||||
}
|
||||
let stdin: HookStdin;
|
||||
try {
|
||||
stdin = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
logHookError(`stdin parse failed: ${(e as Error).message}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const toolName = stdin.tool_name || '';
|
||||
if (
|
||||
toolName !== 'AskUserQuestion' &&
|
||||
!toolName.match(/^mcp__.+__AskUserQuestion$/)
|
||||
) {
|
||||
// Matcher should have filtered this out; defensive no-op.
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const questions = stdin.tool_input?.questions || [];
|
||||
if (questions.length === 0) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const skill = detectSkill(stdin.cwd);
|
||||
const choices = extractUserChoices(stdin.tool_response, questions.length);
|
||||
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
const q = questions[i];
|
||||
const qText = q.question || '';
|
||||
if (!qText) continue;
|
||||
|
||||
const opts = optionLabels(q.options || []);
|
||||
const { id, stripped_question } = extractQuestionId(skill, qText, opts);
|
||||
const recommended = extractRecommended(stripped_question, opts);
|
||||
const summary = stripped_question.slice(0, 200);
|
||||
const choice = choices[i] || { choice: '__unknown__' };
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
skill,
|
||||
question_id: id,
|
||||
question_summary: summary,
|
||||
options_count: opts.length,
|
||||
user_choice: String(choice.choice).slice(0, 64),
|
||||
source: choice.free_text ? 'auq-other' : 'hook',
|
||||
session_id: stdin.session_id?.slice(0, 64),
|
||||
tool_use_id: stdin.tool_use_id?.slice(0, 128),
|
||||
};
|
||||
if (recommended) payload.recommended = recommended.slice(0, 64);
|
||||
if (choice.free_text) payload.free_text = String(choice.free_text);
|
||||
|
||||
spawnLog(payload, stdin.cwd);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
logHookError(`main crash: ${(e as Error).message}`);
|
||||
process.exit(0);
|
||||
});
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bash shim — Claude Code hooks run `command` strings via /bin/sh, so this
|
||||
# wrapper makes the TypeScript hook executable via bun. Settings.json
|
||||
# references this file directly.
|
||||
set -e
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec bun "$HERE/question-preference-hook.ts"
|
||||
@@ -0,0 +1,480 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* PreToolUse hook for AskUserQuestion (Claude Code, plan-tune cathedral T6).
|
||||
*
|
||||
* Enforces never-ask / always-ask / ask-only-for-one-way preferences
|
||||
* deterministically — no agent compliance required.
|
||||
*
|
||||
* Decision tree (per question in tool_input.questions):
|
||||
* 1. Extract question_id via marker (<gstack-qid:foo-bar>). If no marker,
|
||||
* enforcement is skipped for this question (D18 — hash IDs are
|
||||
* observed-only, never used as preference keys).
|
||||
* 2. Look up door_type from scripts/question-registry.ts (default two-way).
|
||||
* 3. Read preferences with precedence: project-local > global (D8).
|
||||
* 4. Apply:
|
||||
* never-ask + one-way → defer (safety override; one-way always asks).
|
||||
* never-ask + two-way + marker → deny with auto-decided recommendation
|
||||
* in reason. Mark tool_use_id so PostToolUse logs as 'auto-decided'.
|
||||
* ask-only-for-one-way + two-way + marker → same as never-ask.
|
||||
* always-ask, or no preference → defer.
|
||||
*
|
||||
* Why deny+reason instead of allow+updatedInput:
|
||||
* AskUserQuestion's `updatedInput` shape for "pre-resolve this question"
|
||||
* isn't structurally pinned in Claude Code docs (spike T4 left as open
|
||||
* question). `deny` with a reason that names the auto-decided option is
|
||||
* conservative + reliable: the model receives the rejection feedback,
|
||||
* reads the recommended option from the reason, and proceeds without
|
||||
* re-firing AUQ. When the spike around input mutation lands, we can
|
||||
* swap to allow+updatedInput without changing the contract.
|
||||
*
|
||||
* Recommended-option extraction (per D2):
|
||||
* - First: (recommended) label suffix on an option.
|
||||
* - Fall back: "Recommendation: X" prose match against option labels.
|
||||
* - Refuse to auto-decide if ambiguous (multiple labels OR no parseable
|
||||
* recommendation): defer instead of silent-wrong.
|
||||
*
|
||||
* Always exits 0. Hook errors land in ~/.gstack/hook-errors.log.
|
||||
* See docs/spikes/claude-code-hook-mutation.md for the protocol contract.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { isConductor } from '../../../lib/is-conductor';
|
||||
|
||||
interface HookStdin {
|
||||
session_id?: string;
|
||||
hook_event_name?: string;
|
||||
tool_name?: string;
|
||||
tool_use_id?: string;
|
||||
tool_input?: {
|
||||
questions?: Array<{
|
||||
question?: string;
|
||||
options?: Array<string | { label?: string; description?: string }>;
|
||||
multiSelect?: boolean;
|
||||
}>;
|
||||
};
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
const MARKER_RE = /<gstack-qid:([a-z0-9-]{1,64})>/i;
|
||||
const RECOMMENDED_LABEL_RE = /\(recommended\)\s*$/i;
|
||||
|
||||
function stateRoot(): string {
|
||||
return (
|
||||
process.env.GSTACK_STATE_ROOT ||
|
||||
process.env.GSTACK_HOME ||
|
||||
path.join(os.homedir(), '.gstack')
|
||||
);
|
||||
}
|
||||
|
||||
function logHookError(msg: string): void {
|
||||
try {
|
||||
const sr = stateRoot();
|
||||
fs.mkdirSync(sr, { recursive: true });
|
||||
fs.appendFileSync(
|
||||
path.join(sr, 'hook-errors.log'),
|
||||
`${new Date().toISOString()} question-preference-hook: ${msg}\n`,
|
||||
);
|
||||
} catch {
|
||||
// last-resort swallow
|
||||
}
|
||||
}
|
||||
|
||||
function readStdin(): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
let buf = '';
|
||||
process.stdin.setEncoding('utf-8');
|
||||
process.stdin.on('data', (chunk) => (buf += chunk));
|
||||
process.stdin.on('end', () => resolve(buf));
|
||||
process.stdin.on('error', () => resolve(buf));
|
||||
setTimeout(() => resolve(buf), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function defer(additionalContext?: string): void {
|
||||
const out: Record<string, unknown> = {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'defer',
|
||||
};
|
||||
if (additionalContext) out.additionalContext = additionalContext;
|
||||
process.stdout.write(JSON.stringify({ hookSpecificOutput: out }));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
function deny(reason: string): void {
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'deny',
|
||||
permissionDecisionReason: reason,
|
||||
},
|
||||
}),
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
function readJsonSafe(filePath: string): Record<string, unknown> | null {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface PreferenceLookup {
|
||||
preference: string | undefined;
|
||||
source: 'project' | 'global' | 'none';
|
||||
}
|
||||
|
||||
function lookupPreference(slug: string, questionId: string): PreferenceLookup {
|
||||
const sr = stateRoot();
|
||||
const projectFile = path.join(sr, 'projects', slug, 'question-preferences.json');
|
||||
const globalFile = path.join(sr, 'global-question-preferences.json');
|
||||
|
||||
const project = readJsonSafe(projectFile);
|
||||
if (project && typeof project[questionId] === 'string') {
|
||||
return { preference: project[questionId] as string, source: 'project' };
|
||||
}
|
||||
const global = readJsonSafe(globalFile);
|
||||
if (global && typeof global[questionId] === 'string') {
|
||||
return { preference: global[questionId] as string, source: 'global' };
|
||||
}
|
||||
return { preference: undefined, source: 'none' };
|
||||
}
|
||||
|
||||
interface RegistryEntry {
|
||||
id: string;
|
||||
door_type?: 'one-way' | 'two-way';
|
||||
signal_key?: string;
|
||||
}
|
||||
|
||||
interface MemoryNugget {
|
||||
nugget: string;
|
||||
applies_to_signal_keys: string[];
|
||||
applied_at?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read per-session cache first, fall back to canonical local file. Cache
|
||||
* invalidates by being missing — gstack-distill-apply doesn't touch the
|
||||
* cache because the canonical file is always the source-of-truth on read
|
||||
* miss. Sub-1ms cache reads (D13 perf).
|
||||
*/
|
||||
function loadMemoryNuggets(sessionId: string | undefined): MemoryNugget[] {
|
||||
const sr = stateRoot();
|
||||
const canonical = path.join(sr, 'free-text-memory.json');
|
||||
let nuggets: MemoryNugget[] | null = null;
|
||||
|
||||
if (sessionId) {
|
||||
const cachePath = path.join(sr, 'sessions', sessionId, 'memory-cache.json');
|
||||
try {
|
||||
const cached = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
|
||||
if (Array.isArray(cached.nuggets)) {
|
||||
return cached.nuggets;
|
||||
}
|
||||
} catch {
|
||||
// miss → fall through
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const j = JSON.parse(fs.readFileSync(canonical, 'utf-8'));
|
||||
nuggets = Array.isArray(j.nuggets) ? j.nuggets : [];
|
||||
} catch {
|
||||
nuggets = [];
|
||||
}
|
||||
|
||||
// Write through to the per-session cache so subsequent hooks on this
|
||||
// session take the fast path. Best-effort; never fails the hook.
|
||||
if (sessionId && nuggets) {
|
||||
try {
|
||||
const dir = path.join(sr, 'sessions', sessionId);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'memory-cache.json'),
|
||||
JSON.stringify({ nuggets, cached_at: new Date().toISOString() }, null, 2),
|
||||
);
|
||||
} catch {
|
||||
// swallow
|
||||
}
|
||||
}
|
||||
|
||||
return nuggets || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* For a given signal_key, return up to N nuggets whose applies_to_signal_keys
|
||||
* include it. Sorted by recency (most-recently-applied first), capped.
|
||||
*/
|
||||
function nuggetsForSignal(nuggets: MemoryNugget[], signalKey: string, max = 3): string[] {
|
||||
return nuggets
|
||||
.filter((n) => Array.isArray(n.applies_to_signal_keys) && n.applies_to_signal_keys.includes(signalKey))
|
||||
.sort((a, b) => (b.applied_at || '').localeCompare(a.applied_at || ''))
|
||||
.slice(0, max)
|
||||
.map((n) => n.nugget);
|
||||
}
|
||||
|
||||
let registryCache: Record<string, RegistryEntry> | null = null;
|
||||
|
||||
function loadRegistry(): Record<string, RegistryEntry> {
|
||||
if (registryCache) return registryCache;
|
||||
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');
|
||||
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
|
||||
// (which would require bun resolving the module at hook-invocation time).
|
||||
// Matches entries like:
|
||||
// 'ship-test-failure-triage': {
|
||||
// id: 'ship-test-failure-triage',
|
||||
// ...
|
||||
// door_type: 'one-way',
|
||||
// signal_key: 'test-discipline',
|
||||
// ...
|
||||
// },
|
||||
const blockRe =
|
||||
/'([a-z0-9-]+)':\s*\{[^}]*?door_type:\s*'(one-way|two-way)'[^}]*?\}/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = blockRe.exec(src))) {
|
||||
const [block, id, door_type] = m;
|
||||
const sk = block.match(/signal_key:\s*'([a-z0-9-]+)'/);
|
||||
registryCache[id] = {
|
||||
id,
|
||||
door_type: door_type as 'one-way' | 'two-way',
|
||||
signal_key: sk ? sk[1] : undefined,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
logHookError(`registry load failed: ${(e as Error).message}`);
|
||||
}
|
||||
return registryCache;
|
||||
}
|
||||
|
||||
function optionLabels(opts: Array<string | { label?: string; description?: string }>): string[] {
|
||||
return opts.map((o) => (typeof o === 'string' ? o : o.label || o.description || ''));
|
||||
}
|
||||
|
||||
function extractRecommended(
|
||||
questionText: string,
|
||||
opts: string[],
|
||||
): { recommended: string | undefined; ambiguous: boolean } {
|
||||
const labelMatches = opts.filter((o) => RECOMMENDED_LABEL_RE.test(o));
|
||||
if (labelMatches.length === 1) {
|
||||
return { recommended: labelMatches[0].replace(RECOMMENDED_LABEL_RE, '').trim(), ambiguous: false };
|
||||
}
|
||||
if (labelMatches.length > 1) return { recommended: undefined, ambiguous: true };
|
||||
|
||||
const m = questionText.match(/Recommendation:\s*([^\n]+)/i);
|
||||
if (!m) return { recommended: undefined, ambiguous: false };
|
||||
const recPhrase = m[1].trim();
|
||||
const prefixMatches = opts.filter((o) =>
|
||||
o.toLowerCase().startsWith(recPhrase.toLowerCase().slice(0, 12)),
|
||||
);
|
||||
if (prefixMatches.length === 1) return { recommended: prefixMatches[0], ambiguous: false };
|
||||
if (prefixMatches.length > 1) return { recommended: undefined, ambiguous: true };
|
||||
return { recommended: undefined, ambiguous: false };
|
||||
}
|
||||
|
||||
function slugFromCwd(cwd: string | undefined): string {
|
||||
// Mirror gstack-slug's basename fallback. The full slug resolver shells out
|
||||
// to git, which is too expensive on a hot hook path; the basename is close
|
||||
// enough for preference lookup (preferences are keyed by question_id, slug
|
||||
// is just the directory bucket).
|
||||
if (!cwd) return 'unknown';
|
||||
return path.basename(cwd);
|
||||
}
|
||||
|
||||
function markAutoDecided(sessionId: string | undefined, toolUseId: string | undefined): void {
|
||||
if (!sessionId || !toolUseId) return;
|
||||
try {
|
||||
const sr = stateRoot();
|
||||
const dir = path.join(sr, 'sessions', sessionId);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, `.auto-decided-${toolUseId}`), '');
|
||||
} catch (e) {
|
||||
logHookError(`markAutoDecided failed: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an auto-decided event directly from PreToolUse, since `deny` prevents
|
||||
* the tool from running and PostToolUse never fires. Without this, /plan-tune
|
||||
* Recent auto-decisions would be blind to enforcement hits.
|
||||
*/
|
||||
function logAutoDecided(
|
||||
questionId: string,
|
||||
questionSummary: string,
|
||||
recommended: string,
|
||||
optionsCount: number,
|
||||
sessionId: string | undefined,
|
||||
toolUseId: string | undefined,
|
||||
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,
|
||||
question_summary: questionSummary.slice(0, 200),
|
||||
options_count: optionsCount,
|
||||
user_choice: recommended.slice(0, 64),
|
||||
recommended: recommended.slice(0, 64),
|
||||
source: 'auto-decided',
|
||||
session_id: sessionId?.slice(0, 64),
|
||||
tool_use_id: toolUseId?.slice(0, 128),
|
||||
};
|
||||
spawnSync(bin, [JSON.stringify(payload)], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 3000,
|
||||
// cwd of the originating tool call so gstack-slug resolves to the
|
||||
// project the user is actually in, not the hook script's location.
|
||||
cwd: cwd && fs.existsSync(cwd) ? cwd : undefined,
|
||||
});
|
||||
} catch (e) {
|
||||
logHookError(`logAutoDecided failed: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const raw = await readStdin();
|
||||
if (!raw.trim()) {
|
||||
defer();
|
||||
return;
|
||||
}
|
||||
let stdin: HookStdin;
|
||||
try {
|
||||
stdin = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
logHookError(`stdin parse failed: ${(e as Error).message}`);
|
||||
defer();
|
||||
return;
|
||||
}
|
||||
|
||||
const toolName = stdin.tool_name || '';
|
||||
if (
|
||||
toolName !== 'AskUserQuestion' &&
|
||||
!toolName.match(/^mcp__.+__AskUserQuestion$/)
|
||||
) {
|
||||
defer();
|
||||
return;
|
||||
}
|
||||
|
||||
const questions = stdin.tool_input?.questions || [];
|
||||
if (questions.length === 0) {
|
||||
defer();
|
||||
return;
|
||||
}
|
||||
|
||||
// For multi-question AUQ, enforcement is all-or-nothing per call:
|
||||
// we deny only if ALL questions have marker + never-ask + safe door type.
|
||||
// Mixed cases pass through (defer) so the user still gets to answer.
|
||||
const registry = loadRegistry();
|
||||
const slug = slugFromCwd(stdin.cwd);
|
||||
const memoryNuggets = loadMemoryNuggets(stdin.session_id);
|
||||
|
||||
// Compute Layer 8 memory context inline: any nuggets matching the
|
||||
// signal_keys of the questions in this AUQ get surfaced as additionalContext.
|
||||
// This applies whether we defer OR deny — gives the agent + user the
|
||||
// relevant prior context either way.
|
||||
const contextNuggets: string[] = [];
|
||||
for (const q of questions) {
|
||||
const qText = q.question || '';
|
||||
const marker = qText.match(MARKER_RE);
|
||||
if (!marker) continue;
|
||||
const entry = registry[marker[1]];
|
||||
if (!entry?.signal_key) continue;
|
||||
const hits = nuggetsForSignal(memoryNuggets, entry.signal_key);
|
||||
for (const h of hits) {
|
||||
if (!contextNuggets.includes(h)) contextNuggets.push(h);
|
||||
}
|
||||
}
|
||||
const memoryContext = contextNuggets.length
|
||||
? '[plan-tune memory] Past answers suggest: ' + contextNuggets.join(' | ')
|
||||
: undefined;
|
||||
|
||||
// Determine whether EVERY question is eligible for never-ask auto-decide.
|
||||
// We deliberately do NOT early-return defer on the first ineligible question:
|
||||
// a Conductor session still needs the [conductor] prose deny as a fallback,
|
||||
// so we compute eligibility, then branch. memoryContext is preserved on every
|
||||
// non-enforcing exit. (All-or-nothing per-call semantics are unchanged: any
|
||||
// ineligible question makes the whole call not auto-decidable.)
|
||||
const autoDecisions: Array<{ id: string; recommended: string }> = [];
|
||||
let fullyAutoDecidable = true;
|
||||
for (const q of questions) {
|
||||
const qText = q.question || '';
|
||||
const marker = qText.match(MARKER_RE);
|
||||
if (!marker) { fullyAutoDecidable = false; break; }
|
||||
const questionId = marker[1];
|
||||
const pref = lookupPreference(slug, questionId);
|
||||
if (!pref.preference || pref.preference === 'always-ask') { fullyAutoDecidable = false; break; }
|
||||
|
||||
const entry = registry[questionId];
|
||||
const doorType = entry?.door_type || 'two-way';
|
||||
// Safety override — even never-ask doesn't bypass one-way doors.
|
||||
if (doorType === 'one-way') { fullyAutoDecidable = false; break; }
|
||||
|
||||
const opts = optionLabels(q.options || []);
|
||||
const { recommended, ambiguous } = extractRecommended(qText, opts);
|
||||
// Refuse-on-ambiguous per D2 — fail safe.
|
||||
if (!recommended || ambiguous) { fullyAutoDecidable = false; break; }
|
||||
autoDecisions.push({ id: questionId, recommended });
|
||||
}
|
||||
|
||||
if (fullyAutoDecidable && autoDecisions.length > 0) {
|
||||
// All questions were eligible for enforcement.
|
||||
markAutoDecided(stdin.session_id, stdin.tool_use_id);
|
||||
|
||||
// Log each auto-decided question now, since deny prevents PostToolUse from
|
||||
// firing. /plan-tune Recent auto-decisions reads source=auto-decided events.
|
||||
for (let i = 0; i < autoDecisions.length; i++) {
|
||||
const d = autoDecisions[i];
|
||||
const q = questions[i];
|
||||
const qText = (q.question || '').replace(MARKER_RE, '').trim();
|
||||
const opts = optionLabels(q.options || []);
|
||||
logAutoDecided(d.id, qText, d.recommended, opts.length, stdin.session_id, stdin.tool_use_id, stdin.cwd);
|
||||
}
|
||||
|
||||
const reasonLines = autoDecisions.map(
|
||||
(d) =>
|
||||
`[plan-tune auto-decide] ${d.id} → ${d.recommended} (your never-ask preference). Proceed with that option without re-prompting. Change with /plan-tune.`,
|
||||
);
|
||||
deny(reasonLines.join('\n'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Not fully auto-decidable. In Conductor, AskUserQuestion is unreliable
|
||||
// (native is disabled, the mcp__conductor__AskUserQuestion variant is flaky),
|
||||
// so deny the tool and redirect to a prose decision brief. This is TRANSPORT
|
||||
// AVOIDANCE, not preference enforcement: it fires regardless of marker,
|
||||
// preference, or door type — including one-way doors, which must reach the
|
||||
// human via prose rather than the unreliable tool.
|
||||
if (isConductor()) {
|
||||
const conductorReason =
|
||||
'[conductor] AskUserQuestion is unreliable in Conductor (native disabled, MCP variant flaky). ' +
|
||||
'Do NOT call AskUserQuestion (native or any mcp__*__AskUserQuestion). Render this decision as a ' +
|
||||
'PROSE decision brief now: a D<N> label, an ELI10 of the issue, a Recommendation line, then one ' +
|
||||
'paragraph per choice carrying its `(recommended)` marker and `Completeness: X/10`; tell the user ' +
|
||||
'to reply with a letter, then STOP. For a one-way/destructive confirmation, require an explicit ' +
|
||||
'typed confirmation and do NOT proceed on a vague reply. Capture the decision with gstack-question-log ' +
|
||||
'(PostToolUse will not fire on a prose path).' +
|
||||
(memoryContext ? `\n${memoryContext}` : '');
|
||||
deny(conductorReason);
|
||||
return;
|
||||
}
|
||||
|
||||
defer(memoryContext);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
logHookError(`main crash: ${(e as Error).message}`);
|
||||
defer();
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { HostConfig } from '../scripts/host-config';
|
||||
|
||||
const codex: HostConfig = {
|
||||
name: 'codex',
|
||||
displayName: 'OpenAI Codex CLI',
|
||||
cliCommand: 'codex',
|
||||
cliAliases: ['agents'],
|
||||
|
||||
globalRoot: '.codex/skills/gstack',
|
||||
localSkillRoot: '.agents/skills/gstack',
|
||||
hostSubdir: '.agents',
|
||||
usesEnvVars: true,
|
||||
|
||||
frontmatter: {
|
||||
mode: 'allowlist',
|
||||
keepFields: ['name', 'description'],
|
||||
descriptionLimit: 1024,
|
||||
descriptionLimitBehavior: 'error',
|
||||
},
|
||||
|
||||
generation: {
|
||||
generateMetadata: true,
|
||||
metadataFormat: 'openai.yaml',
|
||||
skipSkills: ['codex'], // Codex skill is a Claude wrapper around codex exec
|
||||
},
|
||||
|
||||
pathRewrites: [
|
||||
{ from: '~/.claude/skills/gstack', to: '$GSTACK_ROOT' },
|
||||
{ from: '.claude/skills/gstack', to: '.agents/skills/gstack' },
|
||||
{ from: '.claude/skills/review', to: '.agents/skills/gstack/review' },
|
||||
{ from: '.claude/skills', to: '.agents/skills' },
|
||||
],
|
||||
|
||||
suppressedResolvers: [
|
||||
'DESIGN_OUTSIDE_VOICES', // design.ts:485 — Codex can't invoke itself
|
||||
'ADVERSARIAL_STEP', // review.ts:408 — Codex can't invoke itself
|
||||
'CODEX_SECOND_OPINION', // review.ts:257 — Codex can't invoke itself
|
||||
'CODEX_PLAN_REVIEW', // review.ts:541 — Codex can't invoke itself
|
||||
'REVIEW_ARMY', // review-army.ts:180 — Codex shouldn't orchestrate
|
||||
'GBRAIN_CONTEXT_LOAD',
|
||||
'GBRAIN_SAVE_RESULTS',
|
||||
],
|
||||
|
||||
runtimeRoot: {
|
||||
globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'],
|
||||
globalFiles: {
|
||||
'review': ['checklist.md', 'TODOS-format.md'],
|
||||
},
|
||||
},
|
||||
sidecar: {
|
||||
path: '.agents/skills/gstack',
|
||||
symlinks: ['bin', 'browse', 'review', 'qa', 'ETHOS.md'],
|
||||
},
|
||||
|
||||
install: {
|
||||
prefixable: false,
|
||||
linkingStrategy: 'symlink-generated',
|
||||
},
|
||||
|
||||
coAuthorTrailer: 'Co-Authored-By: OpenAI Codex <noreply@openai.com>',
|
||||
learningsMode: 'basic',
|
||||
boundaryInstruction: 'IMPORTANT: Do NOT read or execute any files under ~/.claude/, ~/.agents/, .claude/skills/, or agents/. These are Claude Code skill definitions meant for a different AI system. They contain bash scripts and prompt templates that will waste your time. Ignore them completely. Do NOT modify agents/openai.yaml. Stay focused on the repository code only.',
|
||||
};
|
||||
|
||||
export default codex;
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { HostConfig } from '../scripts/host-config';
|
||||
|
||||
const cursor: HostConfig = {
|
||||
name: 'cursor',
|
||||
displayName: 'Cursor',
|
||||
cliCommand: 'cursor',
|
||||
cliAliases: [],
|
||||
|
||||
globalRoot: '.cursor/skills/gstack',
|
||||
localSkillRoot: '.cursor/skills/gstack',
|
||||
hostSubdir: '.cursor',
|
||||
usesEnvVars: true,
|
||||
|
||||
frontmatter: {
|
||||
mode: 'allowlist',
|
||||
keepFields: ['name', 'description'],
|
||||
descriptionLimit: null,
|
||||
},
|
||||
|
||||
generation: {
|
||||
generateMetadata: false,
|
||||
skipSkills: ['codex'],
|
||||
},
|
||||
|
||||
pathRewrites: [
|
||||
{ from: '~/.claude/skills/gstack', to: '~/.cursor/skills/gstack' },
|
||||
{ from: '.claude/skills/gstack', to: '.cursor/skills/gstack' },
|
||||
{ from: '.claude/skills', to: '.cursor/skills' },
|
||||
],
|
||||
|
||||
suppressedResolvers: ['GBRAIN_CONTEXT_LOAD', 'GBRAIN_SAVE_RESULTS'],
|
||||
|
||||
runtimeRoot: {
|
||||
globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'],
|
||||
globalFiles: {
|
||||
'review': ['checklist.md', 'TODOS-format.md'],
|
||||
},
|
||||
},
|
||||
|
||||
install: {
|
||||
prefixable: false,
|
||||
linkingStrategy: 'symlink-generated',
|
||||
},
|
||||
|
||||
learningsMode: 'basic',
|
||||
};
|
||||
|
||||
export default cursor;
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { HostConfig } from '../scripts/host-config';
|
||||
|
||||
const factory: HostConfig = {
|
||||
name: 'factory',
|
||||
displayName: 'Factory Droid',
|
||||
cliCommand: 'droid',
|
||||
cliAliases: ['droid'],
|
||||
|
||||
globalRoot: '.factory/skills/gstack',
|
||||
localSkillRoot: '.factory/skills/gstack',
|
||||
hostSubdir: '.factory',
|
||||
usesEnvVars: true,
|
||||
|
||||
frontmatter: {
|
||||
mode: 'allowlist',
|
||||
keepFields: ['name', 'description', 'user-invocable'],
|
||||
descriptionLimit: null,
|
||||
extraFields: {
|
||||
'user-invocable': true,
|
||||
},
|
||||
conditionalFields: [
|
||||
{ if: { sensitive: true }, add: { 'disable-model-invocation': true } },
|
||||
],
|
||||
},
|
||||
|
||||
generation: {
|
||||
generateMetadata: false,
|
||||
skipSkills: ['codex'], // Codex skill is a Claude wrapper around codex exec
|
||||
},
|
||||
|
||||
pathRewrites: [
|
||||
{ from: '~/.claude/skills/gstack', to: '$GSTACK_ROOT' },
|
||||
{ from: '.claude/skills/gstack', to: '.factory/skills/gstack' },
|
||||
{ from: '.claude/skills/review', to: '.factory/skills/gstack/review' },
|
||||
{ from: '.claude/skills', to: '.factory/skills' },
|
||||
],
|
||||
toolRewrites: {
|
||||
'use the Bash tool': 'run this command',
|
||||
'use the Write tool': 'create this file',
|
||||
'use the Read tool': 'read the file',
|
||||
'use the Agent tool': 'dispatch a subagent',
|
||||
'use the Grep tool': 'search for',
|
||||
'use the Glob tool': 'find files matching',
|
||||
},
|
||||
|
||||
suppressedResolvers: ['GBRAIN_CONTEXT_LOAD', 'GBRAIN_SAVE_RESULTS'],
|
||||
|
||||
runtimeRoot: {
|
||||
globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'],
|
||||
globalFiles: {
|
||||
'review': ['checklist.md', 'TODOS-format.md'],
|
||||
},
|
||||
},
|
||||
|
||||
install: {
|
||||
prefixable: false,
|
||||
linkingStrategy: 'symlink-generated',
|
||||
},
|
||||
|
||||
coAuthorTrailer: 'Co-Authored-By: Factory Droid <droid@users.noreply.github.com>',
|
||||
learningsMode: 'full',
|
||||
};
|
||||
|
||||
export default factory;
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { HostConfig } from '../scripts/host-config';
|
||||
|
||||
/**
|
||||
* GBrain host config.
|
||||
* Compatible with GBrain >= v0.10.0 (doctor --fast --json, search CLI, entity enrichment).
|
||||
* When updating, check INSTALL_FOR_AGENTS.md in the GBrain repo for breaking changes.
|
||||
*/
|
||||
const gbrain: HostConfig = {
|
||||
name: 'gbrain',
|
||||
displayName: 'GBrain',
|
||||
cliCommand: 'gbrain',
|
||||
cliAliases: [],
|
||||
|
||||
globalRoot: '.gbrain/skills/gstack',
|
||||
localSkillRoot: '.gbrain/skills/gstack',
|
||||
hostSubdir: '.gbrain',
|
||||
usesEnvVars: true,
|
||||
|
||||
frontmatter: {
|
||||
mode: 'allowlist',
|
||||
keepFields: ['name', 'description', 'triggers'],
|
||||
descriptionLimit: null,
|
||||
},
|
||||
|
||||
generation: {
|
||||
generateMetadata: false,
|
||||
skipSkills: ['codex'],
|
||||
includeSkills: [],
|
||||
},
|
||||
|
||||
pathRewrites: [
|
||||
{ from: '~/.claude/skills/gstack', to: '~/.gbrain/skills/gstack' },
|
||||
{ from: '.claude/skills/gstack', to: '.gbrain/skills/gstack' },
|
||||
{ from: '.claude/skills', to: '.gbrain/skills' },
|
||||
{ from: 'CLAUDE.md', to: 'AGENTS.md' },
|
||||
],
|
||||
toolRewrites: {
|
||||
'use the Bash tool': 'use the exec tool',
|
||||
'use the Write tool': 'use the write tool',
|
||||
'use the Read tool': 'use the read tool',
|
||||
'use the Edit tool': 'use the edit tool',
|
||||
'use the Agent tool': 'use sessions_spawn',
|
||||
'use the Grep tool': 'search for',
|
||||
'use the Glob tool': 'find files matching',
|
||||
'the Bash tool': 'the exec tool',
|
||||
'the Read tool': 'the read tool',
|
||||
'the Write tool': 'the write tool',
|
||||
'the Edit tool': 'the edit tool',
|
||||
},
|
||||
|
||||
// GBrain gets brain-aware resolvers. All other hosts suppress these.
|
||||
suppressedResolvers: [
|
||||
'DESIGN_OUTSIDE_VOICES',
|
||||
'ADVERSARIAL_STEP',
|
||||
'CODEX_SECOND_OPINION',
|
||||
'CODEX_PLAN_REVIEW',
|
||||
'REVIEW_ARMY',
|
||||
// NOTE: GBRAIN_CONTEXT_LOAD and GBRAIN_SAVE_RESULTS are NOT suppressed here.
|
||||
// GBrain is the only host that gets brain-first lookup and save-to-brain behavior.
|
||||
],
|
||||
|
||||
runtimeRoot: {
|
||||
globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'],
|
||||
globalFiles: {
|
||||
'review': ['checklist.md', 'TODOS-format.md'],
|
||||
},
|
||||
},
|
||||
|
||||
install: {
|
||||
prefixable: false,
|
||||
linkingStrategy: 'symlink-generated',
|
||||
},
|
||||
|
||||
coAuthorTrailer: 'Co-Authored-By: GBrain Agent <agent@gbrain.dev>',
|
||||
learningsMode: 'basic',
|
||||
};
|
||||
|
||||
export default gbrain;
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { HostConfig } from '../scripts/host-config';
|
||||
|
||||
const hermes: HostConfig = {
|
||||
name: 'hermes',
|
||||
displayName: 'Hermes',
|
||||
cliCommand: 'hermes',
|
||||
cliAliases: [],
|
||||
|
||||
globalRoot: '.hermes/skills/gstack',
|
||||
localSkillRoot: '.hermes/skills/gstack',
|
||||
hostSubdir: '.hermes',
|
||||
usesEnvVars: true,
|
||||
|
||||
frontmatter: {
|
||||
mode: 'allowlist',
|
||||
keepFields: ['name', 'description'],
|
||||
descriptionLimit: null,
|
||||
},
|
||||
|
||||
generation: {
|
||||
generateMetadata: false,
|
||||
skipSkills: ['codex'],
|
||||
includeSkills: [],
|
||||
},
|
||||
|
||||
pathRewrites: [
|
||||
{ from: '~/.claude/skills/gstack', to: '~/.hermes/skills/gstack' },
|
||||
{ from: '.claude/skills/gstack', to: '.hermes/skills/gstack' },
|
||||
{ from: '.claude/skills', to: '.hermes/skills' },
|
||||
{ from: 'CLAUDE.md', to: 'AGENTS.md' },
|
||||
],
|
||||
toolRewrites: {
|
||||
'use the Bash tool': 'use the terminal tool',
|
||||
'use the Write tool': 'use the patch tool',
|
||||
'use the Read tool': 'use the read_file tool',
|
||||
'use the Edit tool': 'use the patch tool',
|
||||
'use the Agent tool': 'use delegate_task',
|
||||
'use the Grep tool': 'search for',
|
||||
'use the Glob tool': 'find files matching',
|
||||
'the Bash tool': 'the terminal tool',
|
||||
'the Read tool': 'the read_file tool',
|
||||
'the Write tool': 'the patch tool',
|
||||
'the Edit tool': 'the patch tool',
|
||||
},
|
||||
|
||||
suppressedResolvers: [
|
||||
'DESIGN_OUTSIDE_VOICES',
|
||||
'ADVERSARIAL_STEP',
|
||||
'CODEX_SECOND_OPINION',
|
||||
'CODEX_PLAN_REVIEW',
|
||||
'REVIEW_ARMY',
|
||||
// GBRAIN_CONTEXT_LOAD and GBRAIN_SAVE_RESULTS are NOT suppressed.
|
||||
// The resolvers handle GBrain-not-installed gracefully ("proceed without brain context").
|
||||
// If Hermes has GBrain as a mod, brain features activate automatically.
|
||||
],
|
||||
|
||||
runtimeRoot: {
|
||||
globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'],
|
||||
globalFiles: {
|
||||
'review': ['checklist.md', 'TODOS-format.md'],
|
||||
},
|
||||
},
|
||||
|
||||
install: {
|
||||
prefixable: false,
|
||||
linkingStrategy: 'symlink-generated',
|
||||
},
|
||||
|
||||
coAuthorTrailer: 'Co-Authored-By: Hermes Agent <agent@nousresearch.com>',
|
||||
learningsMode: 'basic',
|
||||
};
|
||||
|
||||
export default hermes;
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Host config registry.
|
||||
*
|
||||
* Import all host configs and derive the Host union type.
|
||||
* Adding a new host: create hosts/myhost.ts, import here, add to ALL_HOST_CONFIGS.
|
||||
*/
|
||||
|
||||
import type { HostConfig } from '../scripts/host-config';
|
||||
import claude from './claude';
|
||||
import codex from './codex';
|
||||
import factory from './factory';
|
||||
import kiro from './kiro';
|
||||
import opencode from './opencode';
|
||||
import slate from './slate';
|
||||
import cursor from './cursor';
|
||||
import openclaw from './openclaw';
|
||||
import hermes from './hermes';
|
||||
import gbrain from './gbrain';
|
||||
|
||||
/** All registered host configs. Add new hosts here. */
|
||||
export const ALL_HOST_CONFIGS: HostConfig[] = [claude, codex, factory, kiro, opencode, slate, cursor, openclaw, hermes, gbrain];
|
||||
|
||||
/** Map from host name to config. */
|
||||
export const HOST_CONFIG_MAP: Record<string, HostConfig> = Object.fromEntries(
|
||||
ALL_HOST_CONFIGS.map(c => [c.name, c])
|
||||
);
|
||||
|
||||
/** Union type of all host names, derived from configs. */
|
||||
export type Host = (typeof ALL_HOST_CONFIGS)[number]['name'];
|
||||
|
||||
/** All host names as a string array (for CLI arg validation, etc.). */
|
||||
export const ALL_HOST_NAMES: string[] = ALL_HOST_CONFIGS.map(c => c.name);
|
||||
|
||||
/** Get a host config by name. Throws if not found. */
|
||||
export function getHostConfig(name: string): HostConfig {
|
||||
const config = HOST_CONFIG_MAP[name];
|
||||
if (!config) {
|
||||
throw new Error(`Unknown host '${name}'. Valid hosts: ${ALL_HOST_NAMES.join(', ')}`);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a host name from a CLI argument, handling aliases.
|
||||
* e.g., 'agents' → 'codex', 'droid' → 'factory'
|
||||
*/
|
||||
export function resolveHostArg(arg: string): string {
|
||||
// Direct name match
|
||||
if (HOST_CONFIG_MAP[arg]) return arg;
|
||||
|
||||
// Alias match
|
||||
for (const config of ALL_HOST_CONFIGS) {
|
||||
if (config.cliAliases?.includes(arg)) return config.name;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown host '${arg}'. Valid hosts: ${ALL_HOST_NAMES.join(', ')}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hosts that are NOT the primary host (Claude).
|
||||
* These are the hosts that need generated skill docs.
|
||||
*/
|
||||
export function getExternalHosts(): HostConfig[] {
|
||||
return ALL_HOST_CONFIGS.filter(c => c.name !== 'claude');
|
||||
}
|
||||
|
||||
// Re-export individual configs for direct import
|
||||
export { claude, codex, factory, kiro, opencode, slate, cursor, openclaw, hermes, gbrain };
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { HostConfig } from '../scripts/host-config';
|
||||
|
||||
const kiro: HostConfig = {
|
||||
name: 'kiro',
|
||||
displayName: 'Kiro',
|
||||
cliCommand: 'kiro-cli',
|
||||
cliAliases: [],
|
||||
|
||||
globalRoot: '.kiro/skills/gstack',
|
||||
localSkillRoot: '.kiro/skills/gstack',
|
||||
hostSubdir: '.kiro',
|
||||
usesEnvVars: true,
|
||||
|
||||
frontmatter: {
|
||||
mode: 'allowlist',
|
||||
keepFields: ['name', 'description'],
|
||||
descriptionLimit: null,
|
||||
},
|
||||
|
||||
generation: {
|
||||
generateMetadata: false,
|
||||
skipSkills: ['codex'], // Codex skill is a Claude wrapper around codex exec
|
||||
},
|
||||
|
||||
pathRewrites: [
|
||||
{ from: '~/.claude/skills/gstack', to: '~/.kiro/skills/gstack' },
|
||||
{ from: '.claude/skills/gstack', to: '.kiro/skills/gstack' },
|
||||
{ from: '.claude/skills', to: '.kiro/skills' },
|
||||
{ from: '~/.codex/skills/gstack', to: '~/.kiro/skills/gstack' },
|
||||
{ from: '.codex/skills', to: '.kiro/skills' },
|
||||
],
|
||||
|
||||
suppressedResolvers: ['GBRAIN_CONTEXT_LOAD', 'GBRAIN_SAVE_RESULTS'],
|
||||
|
||||
runtimeRoot: {
|
||||
globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'],
|
||||
globalFiles: {
|
||||
'review': ['checklist.md', 'TODOS-format.md'],
|
||||
},
|
||||
},
|
||||
|
||||
install: {
|
||||
prefixable: false,
|
||||
linkingStrategy: 'symlink-generated',
|
||||
},
|
||||
|
||||
learningsMode: 'basic',
|
||||
};
|
||||
|
||||
export default kiro;
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { HostConfig } from '../scripts/host-config';
|
||||
|
||||
const openclaw: HostConfig = {
|
||||
name: 'openclaw',
|
||||
displayName: 'OpenClaw',
|
||||
cliCommand: 'openclaw',
|
||||
cliAliases: [],
|
||||
|
||||
globalRoot: '.openclaw/skills/gstack',
|
||||
localSkillRoot: '.openclaw/skills/gstack',
|
||||
hostSubdir: '.openclaw',
|
||||
usesEnvVars: true,
|
||||
|
||||
frontmatter: {
|
||||
mode: 'allowlist',
|
||||
keepFields: ['name', 'description'],
|
||||
descriptionLimit: null,
|
||||
extraFields: {
|
||||
version: '0.15.2.0',
|
||||
},
|
||||
},
|
||||
|
||||
generation: {
|
||||
generateMetadata: false,
|
||||
skipSkills: ['codex'],
|
||||
includeSkills: [],
|
||||
},
|
||||
|
||||
pathRewrites: [
|
||||
{ from: '~/.claude/skills/gstack', to: '~/.openclaw/skills/gstack' },
|
||||
{ from: '.claude/skills/gstack', to: '.openclaw/skills/gstack' },
|
||||
{ from: '.claude/skills', to: '.openclaw/skills' },
|
||||
{ from: 'CLAUDE.md', to: 'AGENTS.md' },
|
||||
],
|
||||
toolRewrites: {
|
||||
'use the Bash tool': 'use the exec tool',
|
||||
'use the Write tool': 'use the write tool',
|
||||
'use the Read tool': 'use the read tool',
|
||||
'use the Edit tool': 'use the edit tool',
|
||||
'use the Agent tool': 'use sessions_spawn',
|
||||
'use the Grep tool': 'search for',
|
||||
'use the Glob tool': 'find files matching',
|
||||
'the Bash tool': 'the exec tool',
|
||||
'the Read tool': 'the read tool',
|
||||
'the Write tool': 'the write tool',
|
||||
'the Edit tool': 'the edit tool',
|
||||
},
|
||||
|
||||
// Suppress Claude-specific preamble sections that don't apply to OpenClaw
|
||||
suppressedResolvers: [
|
||||
'DESIGN_OUTSIDE_VOICES',
|
||||
'ADVERSARIAL_STEP',
|
||||
'CODEX_SECOND_OPINION',
|
||||
'CODEX_PLAN_REVIEW',
|
||||
'REVIEW_ARMY',
|
||||
'GBRAIN_CONTEXT_LOAD',
|
||||
'GBRAIN_SAVE_RESULTS',
|
||||
],
|
||||
|
||||
runtimeRoot: {
|
||||
globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'],
|
||||
globalFiles: {
|
||||
'review': ['checklist.md', 'TODOS-format.md'],
|
||||
},
|
||||
},
|
||||
|
||||
install: {
|
||||
prefixable: false,
|
||||
linkingStrategy: 'symlink-generated',
|
||||
},
|
||||
|
||||
coAuthorTrailer: 'Co-Authored-By: OpenClaw Agent <agent@openclaw.ai>',
|
||||
learningsMode: 'basic',
|
||||
};
|
||||
|
||||
export default openclaw;
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { HostConfig } from '../scripts/host-config';
|
||||
|
||||
const opencode: HostConfig = {
|
||||
name: 'opencode',
|
||||
displayName: 'OpenCode',
|
||||
cliCommand: 'opencode',
|
||||
cliAliases: [],
|
||||
|
||||
globalRoot: '.config/opencode/skills/gstack',
|
||||
localSkillRoot: '.opencode/skills/gstack',
|
||||
hostSubdir: '.opencode',
|
||||
usesEnvVars: true,
|
||||
|
||||
frontmatter: {
|
||||
mode: 'allowlist',
|
||||
keepFields: ['name', 'description'],
|
||||
descriptionLimit: null,
|
||||
},
|
||||
|
||||
generation: {
|
||||
generateMetadata: false,
|
||||
skipSkills: ['codex'],
|
||||
},
|
||||
|
||||
pathRewrites: [
|
||||
{ from: '~/.claude/skills/gstack', to: '~/.config/opencode/skills/gstack' },
|
||||
{ from: '.claude/skills/gstack', to: '.opencode/skills/gstack' },
|
||||
{ from: '.claude/skills', to: '.opencode/skills' },
|
||||
],
|
||||
|
||||
suppressedResolvers: ['GBRAIN_CONTEXT_LOAD', 'GBRAIN_SAVE_RESULTS'],
|
||||
|
||||
runtimeRoot: {
|
||||
globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'design/dist', 'gstack-upgrade', 'ETHOS.md', 'review/specialists', 'qa/templates', 'qa/references', 'plan-devex-review/dx-hall-of-fame.md'],
|
||||
globalFiles: {
|
||||
'review': ['checklist.md', 'design-checklist.md', 'greptile-triage.md', 'TODOS-format.md'],
|
||||
},
|
||||
},
|
||||
|
||||
install: {
|
||||
prefixable: false,
|
||||
linkingStrategy: 'symlink-generated',
|
||||
},
|
||||
|
||||
learningsMode: 'basic',
|
||||
};
|
||||
|
||||
export default opencode;
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { HostConfig } from '../scripts/host-config';
|
||||
|
||||
const slate: HostConfig = {
|
||||
name: 'slate',
|
||||
displayName: 'Slate',
|
||||
cliCommand: 'slate',
|
||||
cliAliases: [],
|
||||
|
||||
globalRoot: '.slate/skills/gstack',
|
||||
localSkillRoot: '.slate/skills/gstack',
|
||||
hostSubdir: '.slate',
|
||||
usesEnvVars: true,
|
||||
|
||||
frontmatter: {
|
||||
mode: 'allowlist',
|
||||
keepFields: ['name', 'description'],
|
||||
descriptionLimit: null,
|
||||
},
|
||||
|
||||
generation: {
|
||||
generateMetadata: false,
|
||||
skipSkills: ['codex'],
|
||||
},
|
||||
|
||||
pathRewrites: [
|
||||
{ from: '~/.claude/skills/gstack', to: '~/.slate/skills/gstack' },
|
||||
{ from: '.claude/skills/gstack', to: '.slate/skills/gstack' },
|
||||
{ from: '.claude/skills', to: '.slate/skills' },
|
||||
],
|
||||
|
||||
suppressedResolvers: ['GBRAIN_CONTEXT_LOAD', 'GBRAIN_SAVE_RESULTS'],
|
||||
|
||||
runtimeRoot: {
|
||||
globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'],
|
||||
globalFiles: {
|
||||
'review': ['checklist.md', 'TODOS-format.md'],
|
||||
},
|
||||
},
|
||||
|
||||
install: {
|
||||
prefixable: false,
|
||||
linkingStrategy: 'symlink-generated',
|
||||
},
|
||||
|
||||
learningsMode: 'basic',
|
||||
};
|
||||
|
||||
export default slate;
|
||||
Reference in New Issue
Block a user