feat(gateguard): improve first-touch signal quality and session efficiency

- Ask class-specific first-touch questions (instruction, test, prose,
  config, code); code questions are unchanged.
- Credit a completed Glob/Grep/LS or shell search from the current human
  turn that covered the target; Read and exclusion globs never count.
- Collapse same-class new-file creations in one directory within a turn.
- Add opt-in GATEGUARD_FACT_FORCE_MAX_DENIALS session cap (from #2755).
- Always gate sensitive targets, judged on lexical and real paths.
- Canonicalize checked-path keys.

Closes #2608

Co-authored-by: SulimanAbdulrazzaq <suliman9000a@gmail.com>
This commit is contained in:
Jeffrey Montoya
2026-09-29 21:49:46 -04:00
co-authored by SulimanAbdulrazzaq
parent c70874fae9
commit e98a9da288
9 changed files with 4878 additions and 38 deletions
File diff suppressed because it is too large Load Diff
+378
View File
@@ -0,0 +1,378 @@
/**
* GateGuard target identity and classification for first-touch
* Edit/Write/MultiEdit targets: canonical path keys, target classes and their
* questions, sensitive-target detection, and sibling-collapse eligibility.
* Stateless; filesystem access is limited to worktree `.git` checks and
* realpath/lstat of the target's parent chain.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const WINDOWS_PATH_PATTERN = /^[a-z]:[\\/]|^\\\\/i;
/** Canonical checked-state key, so `a.py`, `./a.py` and the absolute path share one gate. */
function canonicalPathKey(filePath, data) {
try {
const target = resolveTargetPath(filePath, data);
if (!target) return filePath;
const key = target.resolved.replace(/\\/g, '/');
return target.isWin ? key.toLowerCase() : key;
} catch (_) {
return filePath;
}
}
/**
* Resolve a target to the file the tool will touch. Relative paths resolve
* against the tool's `cwd` first; this deliberately differs from
* `isExemptPath`, whose globs are project-relative.
*/
function resolveTargetPath(filePath, data) {
const base = (data && data.cwd) || process.env.CLAUDE_PROJECT_DIR || process.cwd();
if (typeof base !== 'string' || typeof filePath !== 'string') return null;
const isWin = WINDOWS_PATH_PATTERN.test(base) || WINDOWS_PATH_PATTERN.test(filePath);
const paths = isWin ? path.win32 : path.posix;
if (!paths.isAbsolute(base)) return null;
return { resolved: paths.resolve(base, filePath), isWin };
}
// --- Target classes ---
const INSTRUCTION_BASENAMES = new Set([
'claude.md',
'agents.md',
'agent.md',
'gemini.md',
'skill.md',
'copilot-instructions.md',
'.cursorrules',
'.windsurfrules'
]);
// Cursor rule files are instructions wherever they live.
const INSTRUCTION_ANYWHERE_EXTS = new Set(['.mdc']);
const INSTRUCTION_DIRS = new Set(['.claude', 'agents', 'commands', 'skills', 'rules', 'hooks', '.cursor', '.codex', '.opencode']);
const INSTRUCTION_EXTS = new Set(['.md', '.mdx', '.txt']);
const TEST_DIRS = new Set(['tests', 'test', '__tests__']);
const TEST_BASENAME_PATTERN = /\.(test|spec)\.|^test_.*\.py$|_test\.(py|go)$/;
const PROSE_EXTS = new Set(['.md', '.mdx', '.txt', '.rst', '.adoc']);
const CONFIG_EXTS = new Set(['.json', '.jsonc', '.yaml', '.yml', '.toml', '.ini']);
const ENV_BASENAME_PATTERN = /^\.env($|\.)/;
// `.env.example.ts` and friends are source files that happen to start with `.env`.
const CODE_EXTS = new Set(['.ts', '.js', '.mjs', '.cjs', '.py', '.go', '.rs', '.java', '.rb']);
/** Classify a path; first match wins: instruction, test, prose, config, code (the fallback). */
function classifyTarget(filePath) {
try {
const normalized = String(filePath || '').replace(/\\/g, '/').toLowerCase();
const segments = normalized.split('/').filter(Boolean);
const base = segments.pop() || '';
const ext = path.posix.extname(base);
if (INSTRUCTION_BASENAMES.has(base) || INSTRUCTION_ANYWHERE_EXTS.has(ext)) return 'instruction';
if (INSTRUCTION_EXTS.has(ext) && segments.some(segment => INSTRUCTION_DIRS.has(segment))) return 'instruction';
if (ext === '.md' && base.includes('instructions') && segments.includes('.github')) return 'instruction';
if (TEST_BASENAME_PATTERN.test(base) || segments.some(segment => TEST_DIRS.has(segment))) return 'test';
if (PROSE_EXTS.has(ext)) return 'prose';
if (CONFIG_EXTS.has(ext) || (ENV_BASENAME_PATTERN.test(base) && !CODE_EXTS.has(ext))) return 'config';
return 'code';
} catch (_) {
return 'code';
}
}
const WORKTREE_PREFIX_PATTERN = /^\.claude\/worktrees\/([^/]+)\//;
/** A Windows-style path has no real location on a POSIX host, and vice versa. */
function isHostPathStyle(isWin) {
return isWin === (process.platform === 'win32');
}
function foldKey(nativePath, isWin) {
const key = nativePath.replace(/\\/g, '/');
return isWin ? key.toLowerCase() : key;
}
/** Project root, resolved the way `isExemptPath` resolves it (CLAUDE_PROJECT_DIR first). */
function canonicalProjectRoot(data) {
const root = process.env.CLAUDE_PROJECT_DIR || (data && data.cwd) || process.cwd();
if (typeof root !== 'string') return null;
const isWin = WINDOWS_PATH_PATTERN.test(root);
const paths = isWin ? path.win32 : path.posix;
if (!paths.isAbsolute(root)) return null;
const native = paths.resolve(root);
return { key: foldKey(native, isWin), native, isWin };
}
/** Only a real worktree (its `.git` exists) has its prefix stripped; otherwise the path stays under `.claude`. */
function isRealWorktree(rootNative, isWin, name) {
if (!isHostPathStyle(isWin)) return false;
const paths = isWin ? path.win32 : path.posix;
return fs.existsSync(paths.join(rootNative, '.claude', 'worktrees', name, '.git'));
}
// Windows ignores trailing dots/spaces and treats `name:stream` as `name`.
const STREAM_MARKER = '::$';
/** Normalize segments as Windows resolves them, so `CLAUDE.md.` and `CLAUDE.md::$DATA` name `CLAUDE.md`. */
function normalizeWindowsSegments(classPath, isWin) {
const segments = classPath.split('/');
const last = segments.length - 1;
return segments
.map((segment, index) => {
if (!isWin && !segment.includes(STREAM_MARKER)) return segment;
let name = segment;
const colon = name.indexOf(':');
if (index === last && colon > 0) name = name.slice(0, colon);
name = name.replace(/[. ]+$/, '');
return name || segment;
})
.join('/');
}
/** Project-relative class path for a canonical key, or null when it is outside the root. */
function projectRelativeClassPath(key, root, isWin) {
const target = isWin ? key.toLowerCase() : key;
const rootKey = isWin ? root.key.toLowerCase() : root.key;
const prefix = rootKey.endsWith('/') ? rootKey : `${rootKey}/`;
if (!target.startsWith(prefix)) return null;
let relative = key.slice(prefix.length);
const worktree = relative.match(WORKTREE_PREFIX_PATTERN);
if (worktree && isRealWorktree(root.native, root.isWin, worktree[1])) relative = relative.slice(worktree[0].length);
return normalizeWindowsSegments(relative, isWin);
}
/**
* The path a target is classified by: project-relative, so an ancestor such as
* `/home/me/tests/proj` never leaks into the class, with a real worktree prefix
* stripped. Targets outside the root keep their absolute path.
*/
function classPathFor(filePath, data) {
try {
const key = canonicalPathKey(filePath, data);
const root = canonicalProjectRoot(data);
const isWin = Boolean(root && root.isWin) || WINDOWS_PATH_PATTERN.test(key);
if (!root) return normalizeWindowsSegments(key, isWin);
const relative = projectRelativeClassPath(key, root, isWin);
return relative === null ? normalizeWindowsSegments(key, isWin) : relative;
} catch (_) {
return filePath;
}
}
/** Class of a hook target, judged on its project-relative path. */
function classifyTargetFor(filePath, data) {
return classifyTarget(classPathFor(filePath, data));
}
const SEARCH_THE_TREE = '(search the tree — Glob/Grep, or find/grep via Bash)';
const QUOTE_INSTRUCTION = "Quote the user's current instruction verbatim";
const CLASS_QUESTIONS = {
instruction: () => [
'Name the harness/loader that reads this file (Claude Code, Codex, Cursor, OpenCode, …) and when it loads it',
'Describe what agent behaviour changes as a result',
`Confirm no existing instruction, skill, or agent file already covers this ${SEARCH_THE_TREE}`
],
test: () => [
'Name what behaviour is under test and which module/function it exercises',
`Name the existing test file(s) covering this module, or confirm none exist ${SEARCH_THE_TREE}`
],
prose: isWrite =>
isWrite
? [
`Name any existing doc this supersedes or duplicates ${SEARCH_THE_TREE}`,
'State where it will be linked or referenced from',
'Explain why a new file rather than editing an existing one'
]
: [
`List other docs or code that reference the section being changed ${SEARCH_THE_TREE}`,
'State what the change corrects or adds'
],
config: () => [
'Name which process/tool reads this file and when',
'Describe the effect of the change',
'Confirm no secrets or credentials are being written in plain text'
]
};
const CLASS_CONDENSED_HINTS = {
instruction: () =>
"briefly state which harness loads this file, the agent behaviour it changes, that no existing instruction file covers it, and the user's verbatim instruction, then retry.",
test: () =>
"briefly state the behaviour and module under test, existing test files for it (or none), and the user's verbatim instruction, then retry.",
prose: isWrite =>
isWrite
? "briefly state what this supersedes, where it is linked from, and the user's verbatim instruction, then retry."
: "briefly state what references the changed section, what the change corrects or adds, and the user's verbatim instruction, then retry.",
config: () =>
"briefly state which process reads this file, the effect of the change, that no secrets are written in plain text, and the user's verbatim instruction, then retry."
};
// --- Sensitive targets ---
// Secrets, keys, auth/payment code, migrations and CI workflows always draw the
// first-touch denial. Basename and segment rules are exact, never substrings.
const SENSITIVE_EXTS = new Set(['.pem', '.key', '.p12', '.pfx']);
const SENSITIVE_BASENAME_PATTERN = /^(\.env($|\.)|id_rsa|id_ed25519|credentials|secrets\.)/;
const SENSITIVE_SEGMENTS = new Set(['auth', 'authn', 'authz', 'security', 'secrets', 'payment', 'payments', 'billing', 'migrations']);
const SENSITIVE_PREFIX = '.github/workflows/';
/** True for a sensitive class path; any error or non-string input is sensitive. */
function isSensitiveTarget(classPath) {
try {
if (typeof classPath !== 'string' || !classPath) return true;
const normalized = classPath.replace(/\\/g, '/').toLowerCase();
const segments = normalized.split('/').filter(Boolean);
const base = segments[segments.length - 1] || '';
if (SENSITIVE_BASENAME_PATTERN.test(base) || SENSITIVE_EXTS.has(path.posix.extname(base))) return true;
if (segments.some(segment => SENSITIVE_SEGMENTS.has(segment))) return true;
// Also matched below an ancestor (unverified worktree, target outside the root): fail safe.
return `/${segments.join('/')}`.includes(`/${SENSITIVE_PREFIX}`);
} catch (_) {
return true;
}
}
/**
* Sensitive on the lexical path or on the real (symlink-resolved) location, so
* `src/tools -> ../auth` cannot launder `src/tools/login.py`. Any error is sensitive.
*/
function isSensitiveTargetFor(filePath, data) {
try {
if (typeof filePath !== 'string' || !filePath) return true;
if (isSensitiveTarget(classPathFor(filePath, data))) return true;
return isSensitiveRealTarget(filePath, data);
} catch (_) {
return true;
}
}
/** Unresolvable real locations (dangling symlink, ENOTDIR, EACCES, loops) count as sensitive. */
function isSensitiveRealTarget(filePath, data) {
const target = resolveTargetPath(filePath, data);
if (!target || !isHostPathStyle(target.isWin)) return false;
const paths = target.isWin ? path.win32 : path.posix;
const real = realTargetPath(target.resolved, paths);
if (!real) return true;
const realKey = foldKey(real, target.isWin);
if (realKey === foldKey(target.resolved, target.isWin)) return false;
const root = canonicalProjectRoot(data);
if (root && root.isWin === target.isWin) {
const realRoot = realpathOfNearestAncestor(root.native, paths);
if (!realRoot) return true;
const realRootInfo = { ...root, key: foldKey(realRoot, target.isWin), native: realRoot };
const realClassPath = projectRelativeClassPath(realKey, realRootInfo, target.isWin);
if (realClassPath !== null) return isSensitiveTarget(realClassPath);
}
return isSensitiveTarget(normalizeWindowsSegments(realKey, target.isWin));
}
// --- Sibling collapse eligibility ---
const COLLAPSIBLE_CLASSES = new Set(['code', 'test', 'prose']);
// 8.3 short names (`CLAUDE~1`) can alias any directory, including dot-directories.
const SHORT_NAME_PATTERN = /~\d/;
/** Harness and tooling directories are dot-directories, so any dot segment blocks collapse. */
function isCollapsibleClassPath(classPath, cls) {
if (!COLLAPSIBLE_CLASSES.has(cls)) return false;
const segments = String(classPath).toLowerCase().split('/').filter(Boolean);
if (segments.length === 0) return false;
return !segments.some(segment => segment.startsWith('.') || SHORT_NAME_PATTERN.test(segment));
}
/** Lexical collapse screen; `collapseGateDir` adds the real-directory screen. */
function isCollapsibleTarget(filePath, data, cls) {
return isCollapsibleClassPath(classPathFor(filePath, data), cls);
}
/** Realpath of a directory via its nearest existing ancestor; null on any error but a plain missing entry. */
function realpathOfNearestAncestor(nativePath, paths) {
const realpath = fs.realpathSync.native || fs.realpathSync;
const missing = [];
let current = nativePath;
for (;;) {
try {
const real = realpath(current);
if (!fs.statSync(real).isDirectory()) return null;
return missing.length > 0 ? paths.join(real, ...missing.reverse()) : real;
} catch (error) {
if (!error || error.code !== 'ENOENT' || !isMissingEntry(current)) return null;
const parent = paths.dirname(current);
if (parent === current) return null;
missing.push(paths.basename(current));
current = parent;
}
}
}
/** Real path of a file target, or of its nearest existing ancestor plus the missing tail. */
function realTargetPath(resolved, paths) {
const realpath = fs.realpathSync.native || fs.realpathSync;
try {
return realpath(resolved);
} catch (error) {
if (!error || error.code !== 'ENOENT' || !isMissingEntry(resolved)) return null;
}
const realDir = realpathOfNearestAncestor(paths.dirname(resolved), paths);
return realDir ? paths.join(realDir, paths.basename(resolved)) : null;
}
/** True when nothing (not even a dangling symlink) exists at the path. */
function isMissingEntry(nativePath) {
try {
fs.lstatSync(nativePath);
return false;
} catch (error) {
return Boolean(error) && error.code === 'ENOENT';
}
}
/**
* Real directory a new file's sibling gate is keyed by, or null when it may not
* collapse. A symlinked directory must keep the class and pass the same screen,
* so `src/tools -> .claude/hooks` never collapses.
*/
function collapseGateDir(filePath, data, cls) {
try {
if (!isCollapsibleTarget(filePath, data, cls)) return null;
const target = resolveTargetPath(filePath, data);
if (!target || !isHostPathStyle(target.isWin)) return null;
const paths = target.isWin ? path.win32 : path.posix;
const lexicalDir = paths.dirname(target.resolved);
const realDir = realpathOfNearestAncestor(lexicalDir, paths);
if (!realDir) return null;
const realKey = foldKey(realDir, target.isWin);
if (realKey === foldKey(lexicalDir, target.isWin)) return realKey;
const root = canonicalProjectRoot(data);
if (!root || root.isWin !== target.isWin) return null;
const realRoot = realpathOfNearestAncestor(root.native, paths);
if (!realRoot) return null;
const realTargetKey = foldKey(paths.join(realDir, paths.basename(target.resolved)), target.isWin);
const realClassPath = projectRelativeClassPath(realTargetKey, { ...root, key: foldKey(realRoot, target.isWin), native: realRoot }, target.isWin);
if (realClassPath === null || classifyTarget(realClassPath) !== cls) return null;
if (isSensitiveTarget(realClassPath)) return null;
return isCollapsibleClassPath(realClassPath, cls) ? realKey : null;
} catch (_) {
return null;
}
}
module.exports = {
WINDOWS_PATH_PATTERN,
COLLAPSIBLE_CLASSES,
CLASS_QUESTIONS,
CLASS_CONDENSED_HINTS,
QUOTE_INSTRUCTION,
resolveTargetPath,
canonicalPathKey,
classifyTarget,
classPathFor,
classifyTargetFor,
isCollapsibleTarget,
collapseGateDir,
isSensitiveTarget,
isSensitiveTargetFor
};
+255
View File
@@ -0,0 +1,255 @@
/**
* GateGuard current-turn transcript scan: one bounded tail read of a Claude
* Code JSONL transcript, walked back to the latest human message or
* compaction. Yields the turn id and the turn's completed, non-error search
* calls. Shell parsing stays in the hook, which shares it with the
* destructive-command gate.
*/
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const { readFileTail, DEFAULT_TRANSCRIPT_TAIL_BYTES } = require('./transcript-context');
const TRANSCRIPT_SCAN_MAX_LINES = 2000;
const SEARCH_TOOL_NAMES = new Set(['Glob', 'Grep', 'LS', 'Bash', 'PowerShell']);
const SHELL_TOOL_NAMES = new Set(['Bash', 'PowerShell']);
const TOOL_USE_ERROR_PREFIX = '<tool_use_error>';
function isObject(value) {
return Boolean(value) && typeof value === 'object';
}
/** Exports never throw: an error here must not escape into the hook's credit or sibling logic. */
function neverThrows(fn, fallback) {
return (...args) => {
try {
return fn(...args);
} catch (_) {
return fallback;
}
};
}
/** Transcript path from the hook payload or CLAUDE_TRANSCRIPT_PATH; '' when none. */
function transcriptPathFor(data) {
const candidate = (isObject(data) && (data.transcript_path || data.transcriptPath)) || process.env.CLAUDE_TRANSCRIPT_PATH;
return typeof candidate === 'string' ? candidate : '';
}
function pendingToolUseId(data) {
return isObject(data) && typeof data.tool_use_id === 'string' ? data.tool_use_id : '';
}
function recordContent(record) {
const message = isObject(record) ? record.message : undefined;
return isObject(message) ? message.content : undefined;
}
function isSkippedTranscriptRecord(record) {
return (
!isObject(record) ||
Array.isArray(record) ||
record.isSidechain === true ||
record.isMeta === true ||
(record.type !== 'user' && record.type !== 'assistant')
);
}
/** Compaction discards the earlier turn: its summary (or boundary marker) starts a new one. */
function isCompactionBoundary(record) {
return (
Boolean(record) &&
typeof record === 'object' &&
record.isSidechain !== true &&
(record.isCompactSummary === true || (record.type === 'system' && record.subtype === 'compact_boundary'))
);
}
const MAX_PROMPT_ID_CHARS = 128;
// Claude Code stamps every user record of a turn with the same `promptId`, so it
// identifies the turn even when the boundary has scrolled out of the tail window.
function recordPromptId(record) {
const id = isObject(record) ? record.promptId : undefined;
return typeof id === 'string' && id && id.length <= MAX_PROMPT_ID_CHARS ? id : null;
}
function boundaryTurnId(record, line) {
if (typeof record.uuid === 'string' && record.uuid) return record.uuid;
return `h:${crypto.createHash('sha256').update(line).digest('hex').slice(0, 16)}`;
}
/**
* Only a non-empty array of `tool_result` blocks is a tool-result record; any
* other user record is a turn boundary, which can only shrink the credit window.
*/
function isToolResultRecord(record) {
const content = recordContent(record);
return (
Array.isArray(content) &&
content.length > 0 &&
content.every(block => block && typeof block === 'object' && block.type === 'tool_result')
);
}
function toolResultText(content) {
if (typeof content === 'string') return content;
if (!Array.isArray(content)) return '';
const first = content.find(block => block && typeof block === 'object' && block.type === 'text');
return first && typeof first.text === 'string' ? first.text : '';
}
function isErrorToolResult(block) {
if (!isObject(block)) return false;
if (block.is_error === true || block.is_error === 'true') return true;
return toolResultText(block.content).trimStart().startsWith(TOOL_USE_ERROR_PREFIX);
}
/**
* Scan the transcript tail back to the latest human turn. Returns null when the
* transcript is unavailable, else the turn id, the turn's searches (newest
* first), batch ids, and shell commands. The tail is bounded to keep the hook fast.
*/
function scanCurrentTurn(transcriptPath, pendingId = '') {
if (typeof transcriptPath !== 'string' || !transcriptPath) return null;
let stat;
try {
stat = fs.statSync(transcriptPath);
} catch (_) {
return null;
}
if (!stat.isFile()) return null;
const tail = readFileTail(transcriptPath, DEFAULT_TRANSCRIPT_TAIL_BYTES);
if (!tail) return null;
const lines = tail.text.split('\n');
const firstLine = Math.max(tail.truncated ? 1 : 0, lines.length - TRANSCRIPT_SCAN_MAX_LINES);
const okResultIds = new Set();
const toolUses = [];
const batchIds = new Map();
let newestMessageId = null;
const windowPromptIds = new Set();
const finish = turnId => ({
turnId,
searches: collectTurnSearches(toolUses, okResultIds, pendingId),
batchIds,
newestMessageId,
shellCommands: collectShellCommands(toolUses)
});
for (let i = lines.length - 1; i >= firstLine; i--) {
const line = lines[i].trim();
if (!line) continue;
let record;
try {
record = JSON.parse(line);
} catch (_) {
continue;
}
if (isCompactionBoundary(record)) return finish(recordPromptId(record) || boundaryTurnId(record, line));
if (isObject(record) && record.type === 'user' && record.isSidechain !== true) {
const id = recordPromptId(record);
if (id) windowPromptIds.add(id);
}
if (isSkippedTranscriptRecord(record)) continue;
if (record.type === 'user') {
if (!isToolResultRecord(record)) return finish(recordPromptId(record) || boundaryTurnId(record, line));
for (const block of recordContent(record)) {
if (typeof block.tool_use_id === 'string' && !isErrorToolResult(block)) {
okResultIds.add(block.tool_use_id);
}
}
continue;
}
if (!isObject(record.message)) continue;
const content = recordContent(record);
const messageId = typeof record.message.id === 'string' && record.message.id ? record.message.id : null;
if (newestMessageId === null && messageId) newestMessageId = messageId;
if (!Array.isArray(content)) continue;
for (let j = content.length - 1; j >= 0; j--) {
const block = content[j];
if (!block || typeof block !== 'object' || block.type !== 'tool_use') continue;
toolUses.push({ block, messageId });
if (messageId && typeof block.id === 'string') batchIds.set(block.id, messageId);
}
}
// No boundary: a clipped window is the current turn only if its promptIds agree;
// two or more mean an unrecognised turn start. An unclipped file has no turn.
if ((tail.truncated || firstLine > 0) && windowPromptIds.size <= 1) {
return finish(windowPromptIds.size === 1 ? [...windowPromptIds][0] : null);
}
return { turnId: null, searches: [], batchIds: new Map(), newestMessageId: null, shellCommands: [] };
}
/** Completed, non-error search calls whose id occurs once, newest first. */
function collectTurnSearches(toolUsesNewestFirst, okResultIds, pendingId) {
const idCounts = new Map();
for (const { block } of toolUsesNewestFirst) {
if (typeof block.id === 'string') idCounts.set(block.id, (idCounts.get(block.id) || 0) + 1);
}
const searches = [];
const priorCalls = toolUsesNewestFirst.filter(({ block }) => !pendingId || block.id !== pendingId);
priorCalls.forEach(({ block, messageId }, index) => {
if (typeof block.id !== 'string' || !okResultIds.has(block.id)) return;
if (idCounts.get(block.id) !== 1) return; // a reused id cannot be tied to one result
if (!SEARCH_TOOL_NAMES.has(block.name)) return;
if (!block.input || typeof block.input !== 'object') return;
searches.push({ name: block.name, input: block.input, callsAgo: index + 1, messageId });
});
return searches;
}
function collectShellCommands(toolUses) {
return toolUses
.filter(({ block }) => SHELL_TOOL_NAMES.has(block.name) && block.input && typeof block.input === 'object')
.map(({ block }) => (typeof block.input.command === 'string' ? block.input.command : ''));
}
/** Assistant message whose searches share the pending call's batch (their results were not yet seen). */
function excludedBatchId(scan, data) {
if (!isObject(scan)) return null;
const pendingId = pendingToolUseId(data);
const located = pendingId && scan.batchIds instanceof Map ? scan.batchIds.get(pendingId) : undefined;
if (located) return located;
return typeof scan.newestMessageId === 'string' ? scan.newestMessageId : null;
}
/** Lazily scans once per hook invocation, shared by every MultiEdit entry. */
function createTurnScanner(data) {
let scanned = false;
let scan = null;
return () => {
if (!scanned) {
scanned = true;
try {
scan = scanCurrentTurn(transcriptPathFor(data), pendingToolUseId(data));
} catch (_) {
scan = null;
}
}
return scan;
};
}
function currentTurnId(scan) {
return scan && typeof scan.turnId === 'string' && scan.turnId ? scan.turnId : null;
}
module.exports = {
TRANSCRIPT_SCAN_MAX_LINES,
SEARCH_TOOL_NAMES,
SHELL_TOOL_NAMES,
transcriptPathFor: neverThrows(transcriptPathFor, ''),
pendingToolUseId: neverThrows(pendingToolUseId, ''),
isCompactionBoundary: neverThrows(isCompactionBoundary, false),
isToolResultRecord: neverThrows(isToolResultRecord, false),
isErrorToolResult: neverThrows(isErrorToolResult, false),
scanCurrentTurn: neverThrows(scanCurrentTurn, null),
excludedBatchId: neverThrows(excludedBatchId, null),
createTurnScanner: neverThrows(createTurnScanner, () => null),
currentTurnId: neverThrows(currentTurnId, null)
};
+1
View File
@@ -281,6 +281,7 @@ module.exports = {
DEFAULT_CONTEXT_THRESHOLD_LARGE,
DEFAULT_CONTEXT_INTERVAL_TOKENS,
DEFAULT_TRANSCRIPT_TAIL_BYTES,
readFileTail,
readLatestContextTokens,
resolveContextWindow,
resolveContextWindowTokens,