mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-11 00:30:21 +02:00
* fix(careful): warn on chained rm even when the last target is safe The safe-exception block whitelisted rm -rf of build artifacts by extracting targets with a single greedy match (.*rm ...), which only ever inspects the LAST rm in the command. A chain like 'rm -rf /; rm -rf node_modules' was therefore judged solely by its trailing safe target and allowed without warning, waving through the destructive 'rm -rf /'. Gate the shortcut to single rm invocations: when any shell separator (; | & newline, incl. JSON-escaped \n/\r from the grep extraction path) is present, fall through to the destructive-pattern check, which warns on any recursive rm. Single-command artifact cleanups still allow. Adds 3 regression tests covering semicolon and && chains in both orders. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * harden(careful): substitution separators + capital -R recursive flag (#2039) Two residual fail-opens in the same guard PR #2040 hardened, both verified by executing the script pre-fix: - rm -rf $(./wipe-all)/node_modules silently allowed: the substitution token ends in a whitelisted suffix and the safe-exception early exit skipped ALL downstream checks. $( and backtick now count as chain separators; plain $VAR expansion stays allowed. - rm -R / silently allowed: both greps required a lowercase r in the flag cluster; capital -R is the documented BSD/macOS recursive flag. Both greps now match -[a-zA-Z]*[rR]. Six new tests: substitution x2 -> ask, capital-R x2 -> ask, rm -Rf node_modules single-command -> still allowed, escaped-newline branch (existing code, previously untested), and a pinned deliberate FP (cd app && rm -rf node_modules -> ask) documenting the fail-closed direction on chains. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(context-restore): prefer the current branch's own checkpoint (#2052) All worktrees of a repo share one origin-derived slug, so they share one `~/.gstack/projects/<slug>/checkpoints/` dir. `/context-restore` loaded the newest checkpoint across the whole dir, so in one worktree it could silently restore a *sibling worktree's* newer checkpoint. Step 1 now orders candidates current-branch-first (read from each file's `branch:` frontmatter), keeping other branches as a fallback. A branch is checked out in at most one worktree, so this stops cross-worktree contamination while preserving Conductor cross-branch handoff: when the current branch has no checkpoint of its own, the full newest-first set is still used. - scan the 200 newest before partitioning so a current-branch checkpoint sitting below a burst of sibling saves is still found; output still capped at 20 - non-git / detached HEAD / branchless legacy saves fall back to the old newest-first behavior (back-compat) - +5 regression tests in context-save-hardening.test.ts (the #2052 bug case fails on the old pipeline); regenerated SKILL.md + proactive-suggestions.json Fixes #2052 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(gbrain): pass --confirm-destructive on drift re-register (#1985) ensureSourceRegistered() handles match-but-different-path by removing the old source then re-adding it at the new path. The remove was issued as `gbrain sources remove <id> --yes`, but gbrain >= 0.42 gates `sources remove` behind `--confirm-destructive` (`--yes` alone no longer suppresses the data-loss prompt). The remove therefore fails with "To proceed, pass --confirm-destructive", which ensureSourceRegistered surfaces as "source registration failed" — aborting the entire /sync-gbrain code stage for any already-registered source whose path has drifted. The memory and brain-sync stages still pass, so the code index silently stops refreshing. The orchestrator's own safeSourcesRemove() already passes --confirm-destructive; this brings the lib helper in line with that convention. Keeps --yes for older gbrain. Tests: extend the fake gbrain shim in gbrain-sources.test.ts to simulate the gbrain >= 0.42 guard (remove without --confirm-destructive exits 1), update the drift re-register assertion, and add a regression test that proves the drift path no longer throws. Both fail on main with the exact "To proceed, pass --confirm-destructive" error and pass with the fix. Fixes #1985 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * harden(gbrain-sources): route drift remove through #1734 guards + realpath drift check Absorbing #2031 un-blocked a destructive remove that bypassed the #1734 data-loss guards: ensureSourceRegistered's drift path issued `gbrain sources remove` directly, without the detectAutopilot + decideSourceRemove checks every other remove routes through via safeSourcesRemove. gbrain >= 0.42's own prompt was accidentally blocking that path; with --confirm-destructive passed it is live again. - Drift remove now refuses LOUDLY (throws, actionable message) while an autopilot is active or when decideSourceRemove disallows; a silent changed=false would hide the drifted registration. - decideSourceRemove's extraArgs (--keep-storage when supported) propagate to the remove call, matching safeSourcesRemove. - Drift is realpath-normalized before being declared: a symlink alias of the same directory (macOS /tmp -> /private/tmp) is a match, not drift — the probable cause of #1985's reporter hitting the remove on an unmoved repo. - Drift fires a loud stderr line (old -> new path); perpetual drift in logs is the trigger for promoting #1985's reindex-in-place design. Tests: autopilot-active refusal (no remove in call log), fail-closed refusal on unreadable sources list, --keep-storage propagation, symlink-alias no-drift; existing drift tests pin the guard probes so a live autopilot on the dev machine can't flip them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(developer-profile): exclude mode:resources rows from SESSION_COUNT, TIER, NUDGE_ELIGIBLE (#2067) Every /office-hours run appends a mode:"resources" bookkeeping row alongside the real session row, so --read double-counted sessions (~2x): tiers promoted early and the builder-to-founder nudge armed prematurely. The file already filtered resources rows for LAST_*/CROSS_PROJECT; the same realSessions filter now feeds SESSION_COUNT/TIER, and the nudge predicate is the faithful allowlist (mode === 'builder') so a future mode #4 fails closed instead of re-opening this bug. 8 regression tests: count vs resources noise, tier boundaries both sides, nudge false-with-noise / true-at-3-builders, cross-project trailing row. Absorbed from PR #1991 by @mvann (fix + tests commits; the PR's version-bump commit is superseded by this wave's consolidated release commit). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(hooks): passThrough() two-branch contract — never emit permissionDecision:'defer' (#2035, #2006) Every AskUserQuestion died with "Tool result missing due to internal error" on current Claude Code builds (Desktop 1.14271.0, CC 2.1.177). Root cause: the question-preference-hook emitted permissionDecision:'defer' on every pass-through path. 'defer' is a real PreToolUse value, but since CC v2.1.89 its semantics are "pause this tool call for external resumption" (headless resume) — never "abstain". Interactive sessions have nothing to resume the paused call, so the tool orphaned. Pre-2.1.89 builds ignored the unknown value, which is why the hook worked when it shipped and broke later. The fix is the two-branch pass-through contract: - no context -> exit 0 with EXACTLY empty stdout - memory nuggets present -> hookSpecificOutput with hookEventName + additionalContext ONLY (the documented shape; plan-tune Layer 8 memory injection ships through this branch and keeps working) defer() is renamed passThrough() so the function says what it does, and docs/spikes/claude-code-hook-mutation.md's protocol contract (cited by the hook header) is corrected in the same commit — it taught '"defer" — let permission flow continue' and was the reintroduction vector. Test contract rewritten in the same commit (13 assertions across 3 files, verified fail-first against the unfixed hook): pass-through paths assert exact-empty stdout (a garbage/partial write cannot slip past an optional-chained parse), the nugget path asserts permissionDecision is ABSENT while additionalContext survives, and a new tripwire asserts no non-deny path ever puts the string "permissionDecision" on stdout. The deny (auto-decide) and Conductor prose-redirect paths are unchanged. Deployment: no migration needed — settings.json points at the absolute bash shim which execs the .ts live; /gstack-upgrade delivers the fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(one-way-doors): unify credential noun net + wire it into the runtime (#2024) Library fix: revoke/reset/rotate now share ONE noun alternation (api key, token, secret, credential, access key, password) with optional plural s?. Pre-fix leaks: "reset my secret", "reset my access key", "revoke my secret" (mismatched per-verb lists) and every plural form ("rotate the credentials", "revoke all tokens" — \b(...)\b cannot match a trailing s). Runtime wiring — the regexes could never fire in production before: - gstack-question-preference --check gains --summary-stdin: the question text pipes via stdin (never argv — summaries carry quotes/newlines/shell metacharacters) and feeds isOneWayDoor alongside the id, so an ad-hoc destructive question with a stored never-ask preference now forces ASK_NORMALLY. Empty/absent stdin keeps exact id-only semantics. - question-preference-hook falls back to classifyQuestion(question text) when the registry lookup misses, so unregistered destructive questions pass through to a human instead of auto-deciding. - question-tuning resolver prose shows the piped form (SKILL.md regen lands in the wave's release commit). Tripwires (verified fail-first): full verbs x nouns x singular/plural matrix with the #2024 repro rows, benign-summary no-over-match rows, stdin transport survival (quotes/newlines), empty-stdin fail-safe, and hook fallback both directions (destructive -> pass-through, benign -> deny). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(design): loud integer-flag contract for --count/--retry/--timeout (#2032) design variants --count abc silently generated ZERO variants and exited 0: parseInt(NaN) flowed through Math.min into the generation loop bound. The same NaN class was live on the two sibling flags in the same file: --retry abc made generate() a silent no-op (attempt <= NaN never true, null output, exit 0) and --timeout abc killed the serve board ~immediately (setTimeout(NaN)). New design/src/flag-utils.ts: parseIntFlag (pure, unit-testable) + normalizeIntFlag (CLI wrapper). Contract matches the --viewports precedent (error loudly on nonsense — these commands spend real image-API money, a silent fixup hides typos from calling agents): undefined -> default; bare flag/empty/non-integer ("3.7" rejected, not truncated)/below-min -> exit 1 with usage hint; above-max -> clamp with stderr warning. --count normalizes at the variants() consumption site so programmatic callers are covered, with the ceiling derived from STYLE_VARIATIONS.length instead of a magic 7; the CLI passes the raw flag through (a pre-parseInt would truncate "3.7"). Tripwires live in test/design-flag-utils.test.ts — deliberately under test/, not design/test/, which is invisible to the bun test glob, TEST_ROOTS, and every workflow (wiring design/test/ into CI is a captured TODO). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gbrain): thin-client state — remote-MCP brains no longer classify as broken-config (#2051) A thin client (remote-HTTP MCP brain, no local engine by design) probed `gbrain sources list`, which gbrain's dispatch guard REFUSES on thin clients (exit 1, no recognized error string), so the classifier fell to its defensive broken-config default and every suppression gate silently hid brain-aware blocks from exactly the users on a shared team brain. New 'thin-client' state, detected PRE-probe from gbrain's own remote_mcp config marker via the existing gbrainConfigPath() helper (mirrors gbrain's isThinClient(); honors GBRAIN_HOME; zero network, immune to error-string drift), with a /thin[- ]client/ stderr backstop in the probe catch. Remote reachability is deliberately NOT probed by the classifier — that is the #1964 pathology; gbrain calls degrade gracefully at use time, and the detect JSON says so honestly (gbrain_thin_client: {probed: false}). The state is admitted at every suppression gate — gstack-gbrain-detect --is-ok (drives setup + gbrain-refresh), gen-skill-docs' detection override, gstack-config gbrain-refresh — while the sync stages (code/memory/dream) SKIP with an accurate reason: code indexing runs on the brain server, memory syncs via the remote brain's artifacts pull. The two consumer classes need opposite answers, which is why this is a distinct state and not a skip-the-probe special case. sync-gbrain Step 1.5 and setup-gbrain prose route thin-client to proceed, never into broken-config remediation. detectMcpMode secondary generalization: url-match against the config's remote_mcp.mcp_url (deterministic — gbrain mounts at the generic /mcp path) -> name pattern gbrain[-_]* -> stdio command token; gbrain_mcp_mode stays a 3-value enum. Tripwires: end-to-end --is-ok exits 0 on a thin-client fixture AND still exits 1 on broken-config (the gate didn't widen); pre-probe + stderr-fallback classifier paths; 4 detectMcpMode identification cases incl. a non-matching url that must NOT false-positive. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * release: v1.60.0.0 — regen SKILL.md, VERSION, CHANGELOG, TODOS follow-ups - Regenerate all SKILL.md from templates (question-tuning --summary-stdin prose from #2024, context-restore branch preference from PR #2054, sync-gbrain/setup-gbrain thin-client prose from #2051) + llms.txt. - VERSION + package.json -> 1.60.0.0 (bin/gstack-next-version, queue-aware: #1815 claims 1.59.0.0, #2213 claims 1.59.1.0). - CHANGELOG release summary + itemized entry crediting @jbetala7 (x3) and @mvann. - TODOS.md: three eng-review follow-ups (design/test CI wiring + documented pre-existing retry-after flake, /context-save worktree identity, gbrain reindex-in-place conditional on the new drift log). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(resolvers): compress --summary-stdin preamble prose to fit parity budget; re-bless ship goldens The v1.57.7.0 parity suite caps investigate's generated size at 1.09x baseline; the #2024 question-tuning prose (duplicated into every tier->=2 skill) tipped it to 1.092. Compressed to a single inline command + short pointer (the full rationale lives in bin/gstack-question-preference's header and the one-way-doors module docs). Ship goldens re-blessed against the final resolver text (conscious template-change acknowledgment, per the golden-file regression contract). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(e2e): office-hours-spec-review turn budget fits the carved skill layout (#2473) The test failed deterministically with error_max_turns at 9 turns on main and this branch alike (CI attempt logs + local main repro). Root cause from the failing transcript: the Spec Review Loop content is carved out of office-hours/SKILL.md into office-hours/sections/, so the agent needs discovery hops (grep SKILL.md -> ls sections/ -> read the section) before it can write — 8 tool turns + the closing text turn = 9 > the 8-turn budget, which predates the carve. Observed failures wrote a CORRECT summary on tool turn 8 and died on the closing turn. maxTurns 8 -> 12. Verified: PASS locally post-fix (7 turns this run — the extra headroom absorbs discovery-path nondeterminism). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(e2e): review-dashboard-via session budget survives runner contention (#2473) The test failed on CI (and its baseline run) with the timeout signature: 0 turns, $0.00, exactly 183s, 3/3 attempts — the spawned claude -p session never emitted a single stream event before the 180s inner timeout. The file's tests run concurrently on one runner; session startup queues behind sibling sessions, and this test had the tightest budget in the file (the 240s-budget tests in the same job passed). A clean local run takes 270s wall for 4 turns, confirming 180s was too tight even without contention. Inner timeout 180s -> 300s; outer bun timeout 240s -> 360s to keep headroom over the inner budget. Verified: PASS locally post-fix (4 turns, 270s). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(e2e): retro-base-branch session budget survives runner contention (#2473) Same class as review-dashboard-via, one test over in the same file: /retro is a long multi-step flow whose clean pass measures 225-239s — a coin flip against the 240s inner budget. First CI run passed at 225s; the rerun timed out at the 240s line on all 3 attempts (exitReason "timeout"); the local verification run passed at 239s, ONE second under the old cap. Inner timeout 240s -> 360s; outer bun timeout 300s -> 480s for headroom. Verified: PASS locally post-fix (17 turns, 239s). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jayesh Betala <jayesh.betala7@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Michael Vann <9221873+mvann@users.noreply.github.com>
512 lines
19 KiB
TypeScript
512 lines
19 KiB
TypeScript
#!/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 → pass through (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 → pass through.
|
|
*
|
|
* Pass-through = exit 0 with empty stdout (or additionalContext-only output
|
|
* when memory nuggets exist) — NEVER permissionDecision:'defer', whose
|
|
* CC v2.1.89+ semantics are pause-for-external-resumption (#2035, #2006).
|
|
*
|
|
* 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): pass through 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';
|
|
import { classifyQuestion } from '../../../scripts/one-way-doors';
|
|
|
|
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 passThrough(additionalContext?: string): void {
|
|
// Abstain = exit 0 with EMPTY stdout (#2035, #2006). Never emit a
|
|
// permissionDecision here: 'defer' is a real PreToolUse value, but since
|
|
// Claude Code v2.1.89 its semantics are "pause this tool call for external
|
|
// resumption" (a headless-resume feature) — NOT "no opinion". In an
|
|
// interactive session nothing resumes the paused call, so every
|
|
// AskUserQuestion died with "Tool result missing due to internal error".
|
|
// additionalContext-only hookSpecificOutput is the documented shape for
|
|
// injecting context (plan-tune memory nuggets) without a decision.
|
|
if (additionalContext) {
|
|
process.stdout.write(
|
|
JSON.stringify({
|
|
hookSpecificOutput: {
|
|
hookEventName: 'PreToolUse',
|
|
additionalContext,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
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()) {
|
|
passThrough();
|
|
return;
|
|
}
|
|
let stdin: HookStdin;
|
|
try {
|
|
stdin = JSON.parse(raw);
|
|
} catch (e) {
|
|
logHookError(`stdin parse failed: ${(e as Error).message}`);
|
|
passThrough();
|
|
return;
|
|
}
|
|
|
|
const toolName = stdin.tool_name || '';
|
|
if (
|
|
toolName !== 'AskUserQuestion' &&
|
|
!toolName.match(/^mcp__.+__AskUserQuestion$/)
|
|
) {
|
|
passThrough();
|
|
return;
|
|
}
|
|
|
|
const questions = stdin.tool_input?.questions || [];
|
|
if (questions.length === 0) {
|
|
passThrough();
|
|
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 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 pass through 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 pass-through 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];
|
|
let doorType: string = entry?.door_type || 'two-way';
|
|
if (!entry) {
|
|
// #2024: an unregistered id used to default straight to two-way without
|
|
// consulting the keyword net, so an ad-hoc DESTRUCTIVE question with a
|
|
// stored never-ask preference auto-decided. classifyQuestion is a pure
|
|
// regex pass over the question text; on any failure keep the default
|
|
// (enforcement still requires an explicit stored preference).
|
|
try {
|
|
if (classifyQuestion({ summary: qText.replace(MARKER_RE, '').trim() }).oneWay) {
|
|
doorType = 'one-way';
|
|
}
|
|
} catch (e) {
|
|
logHookError(`one-way classifier failed: ${(e as Error).message}`);
|
|
}
|
|
}
|
|
// 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;
|
|
}
|
|
|
|
passThrough(memoryContext);
|
|
}
|
|
|
|
main().catch((e) => {
|
|
logHookError(`main crash: ${(e as Error).message}`);
|
|
passThrough();
|
|
});
|