thumbgate 1.29.1 → 1.29.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/.claude/commands/dashboard.md +11 -1
  2. package/.claude/commands/thumbgate-dashboard.md +23 -8
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.well-known/mcp/server-card.json +1 -1
  5. package/README.md +61 -1
  6. package/adapters/claude/.mcp.json +2 -2
  7. package/adapters/forge/forge.yaml +3 -3
  8. package/adapters/mcp/server-stdio.js +88 -2
  9. package/adapters/opencode/opencode.json +1 -1
  10. package/commands/dashboard.md +11 -1
  11. package/commands/thumbgate-dashboard.md +23 -8
  12. package/config/agent-outcome-monitor-thresholds.json +63 -0
  13. package/config/evals/agent-outcomes-baseline.json +17 -0
  14. package/config/evals/agent-outcomes-golden.json +412 -0
  15. package/config/evals/prompt-eval-baseline.json +23 -0
  16. package/config/schemas/task-outcome-receipt.schema.json +296 -0
  17. package/openapi/openapi.yaml +235 -0
  18. package/package.json +19 -6
  19. package/public/index.html +4 -2
  20. package/public/numbers.html +2 -2
  21. package/scripts/agent-outcome-eval.js +130 -0
  22. package/scripts/agent-outcome-monitor.js +261 -0
  23. package/scripts/agent-reasoning-traces.js +8 -9
  24. package/scripts/async-job-runner.js +107 -13
  25. package/scripts/durability/step.js +121 -12
  26. package/scripts/gates-engine.js +431 -18
  27. package/scripts/human-escalation.js +265 -0
  28. package/scripts/hybrid-feedback-context.js +93 -50
  29. package/scripts/judge-reward-function.js +30 -18
  30. package/scripts/prompt-eval.js +81 -4
  31. package/scripts/schedule-manager.js +249 -0
  32. package/scripts/task-outcomes.js +425 -0
  33. package/scripts/tool-contract-validator.js +287 -59
  34. package/scripts/tool-registry.js +143 -0
  35. package/src/api/server.js +127 -5
@@ -121,6 +121,9 @@ const BOOSTED_RISK_MIN_EXAMPLES = 3;
121
121
  const PR_THREAD_RESOLUTION_ACTION = 'pr_thread_resolution_verified_after_commit';
122
122
  const HELPER_BYPASS_ACTION = 'helper_script_modified';
123
123
  const KNOWLEDGE_ENTROPY_THRESHOLD = 0.7;
124
+ // Generous character bound: keeps every affected file for realistic actions while still
125
+ // preventing an unbounded haystack. Chosen over a file-count cap, which dropped targets.
126
+ const MEMORY_GUARD_MAX_SERIALIZED_CHARS = 200000;
124
127
  const KNOWLEDGE_CONFLICT_STRICT_BASH_PATTERN = /\b(?:git\s+push\b|gh\s+pr\s+merge\b|gh\s+release\s+(?:create|delete|edit|upload)\b|(?:npm|yarn|pnpm)\s+publish\b|rm\s+-rf\b|git\s+reset\s+--hard\b|git\s+clean\s+-f[a-z]*|railway\s+(?:deploy|up)\b|gcloud\s+(?:run\s+deploy|app\s+deploy)\b|firebase\s+deploy\b|vercel\s+--prod\b|kubectl\s+(?:apply|delete)\b|terraform\s+(?:apply|destroy)\b)\b/i;
125
128
  const HELPER_SCRIPT_FILE_PATTERN = /(?:^|\/)(?:scripts|bin|tools|tasks|\.githooks|\.github\/workflows)\/|(?:^|\/)(?:package\.json|Makefile|justfile|Taskfile\.ya?ml)$|\.(?:sh|bash|zsh|fish|js|mjs|cjs|ts|tsx|py|rb|pl|ps1|yml|yaml)$/i;
126
129
  const PACKAGE_RUN_PATTERN = /\b(?:npm|yarn|pnpm)\s+run\s+([:@./\w-]+)\b/i;
@@ -948,25 +951,351 @@ function getBranchDiffFiles(repoRoot) {
948
951
  return safeExecFileLines('git', ['diff', '--name-only'], repoRoot);
949
952
  }
950
953
 
954
+ // `git add`/`git commit` accept an explicit pathspec, and when one is present it — not the
955
+ // working tree — defines what the command actually touches. Scanning the whole tree here
956
+ // reported every dirty file as "affected", so in a repo with a large dirty tree (e.g. one
957
+ // shared by several agents) a correctly scoped `git add -- a.js b.js` was reported as
958
+ // thousands of affected files and tripped task-scope / protected-file gates that the command
959
+ // never actually violated. Only fall back to a full tree scan when the command really does
960
+ // stage broadly (`git add .`, `-A`, `-u`, or no pathspec at all).
961
+ const GIT_BROAD_ADD_FLAGS = new Set(['-A', '--all', '-u', '--update', '--no-ignore-removal', '--ignore-removal']);
962
+
963
+ // Minimal shell-word splitter: honours single/double quotes so a quoted path with spaces
964
+ // stays one token. Deliberately does not expand variables or globs — an unresolvable token
965
+ // is treated as "broad" by the callers below rather than guessed at.
966
+ function tokenizeShellWords(segment) {
967
+ const tokens = [];
968
+ let current = '';
969
+ let quote = null;
970
+ let hasContent = false;
971
+ for (let i = 0; i < segment.length; i++) {
972
+ const char = segment[i];
973
+ // A backslash escapes the next character outside single quotes. Without this,
974
+ // `git add my\ dir/file.js` split at the escaped space into two fictional paths
975
+ // and the gates evaluated files git never touches.
976
+ if (char === '\\' && quote !== "'" && i + 1 < segment.length) {
977
+ current += segment[i + 1];
978
+ hasContent = true;
979
+ i += 1;
980
+ continue;
981
+ }
982
+ if (quote) {
983
+ if (char === quote) quote = null;
984
+ else current += char;
985
+ continue;
986
+ }
987
+ if (char === '"' || char === "'") {
988
+ quote = char;
989
+ hasContent = true;
990
+ continue;
991
+ }
992
+ if (/\s/.test(char)) {
993
+ if (hasContent || current) tokens.push(current);
994
+ current = '';
995
+ hasContent = false;
996
+ continue;
997
+ }
998
+ current += char;
999
+ }
1000
+ if (hasContent || current) tokens.push(current);
1001
+ return tokens;
1002
+ }
1003
+
1004
+ // Isolate the `git <sub>` run from a compound command so `git add a.js && git push` only
1005
+ // contributes `a.js` to the add pathspec.
1006
+ function extractGitSubcommandSegments(command, subcommand) {
1007
+ const segments = [];
1008
+ const pattern = new RegExp(`\\bgit\\s+${subcommand}\\b`, 'gi');
1009
+ let match;
1010
+ while ((match = pattern.exec(command)) !== null) {
1011
+ const rest = command.slice(match.index + match[0].length);
1012
+ const stop = rest.search(/(?:&&|\|\||[;|\n])/);
1013
+ segments.push(stop === -1 ? rest : rest.slice(0, stop));
1014
+ }
1015
+ return segments;
1016
+ }
1017
+
1018
+ /**
1019
+ * Resolve the explicit pathspec of a git subcommand.
1020
+ *
1021
+ * @returns {{ broad: boolean, paths: string[] }} `broad` means "no usable pathspec — the
1022
+ * command may touch anything", which keeps the previous full-tree-scan behaviour.
1023
+ */
1024
+ function parseGitPathspec(command, subcommand, options = {}) {
1025
+ // `separatorOnly` is for subcommands whose bare arguments are usually flag VALUES rather
1026
+ // than paths (`git commit -m "msg"`), where only tokens after `--` are a real pathspec.
1027
+ const separatorOnly = options.separatorOnly === true;
1028
+ const segments = extractGitSubcommandSegments(command, subcommand);
1029
+ if (!segments.length) return { broad: true, paths: [] };
1030
+
1031
+ const paths = [];
1032
+ for (const segment of segments) {
1033
+ const tokens = tokenizeShellWords(segment);
1034
+ let afterSeparator = false;
1035
+ let sawPath = false;
1036
+ for (const token of tokens) {
1037
+ if (!token) continue;
1038
+ if (token === '--') { afterSeparator = true; continue; }
1039
+ if (separatorOnly && !afterSeparator) continue;
1040
+ if (!afterSeparator && token.startsWith('-')) {
1041
+ if (GIT_BROAD_ADD_FLAGS.has(token)) return { broad: true, paths: [] };
1042
+ // `--pathspec-from-file` / interactive modes read paths we cannot resolve here.
1043
+ if (/^--pathspec-from-file/.test(token) || token === '-i' || token === '--interactive'
1044
+ || token === '-p' || token === '--patch') {
1045
+ return { broad: true, paths: [] };
1046
+ }
1047
+ continue;
1048
+ }
1049
+ // A shell metacharacter or unexpanded glob/variable means the real pathspec is
1050
+ // unknown at gate time — stay conservative rather than under-reporting.
1051
+ if (/[*?$`]|^~/.test(token)) return { broad: true, paths: [] };
1052
+ if (token === '.' || token === './') return { broad: true, paths: [] };
1053
+ // Git pathspec MAGIC (gitglossary(7)): `:(exclude)x`, `:!x`, `:(icase)x`, `:/`, `:(top)`.
1054
+ // These select a materially different set than the literal string — notably an
1055
+ // exclude-only pathspec behaves as if NO pathspec were given, staging everything else.
1056
+ // Treating them literally let them evade task-scope and protected-file checks entirely.
1057
+ if (token.startsWith(':')) return { broad: true, paths: [] };
1058
+ paths.push(token);
1059
+ sawPath = true;
1060
+ }
1061
+ if (!sawPath) return { broad: true, paths: [] };
1062
+ }
1063
+
1064
+ return paths.length ? { broad: false, paths } : { broad: true, paths: [] };
1065
+ }
1066
+
1067
+ // A pathspec is relative to the shell's working directory, NOT the repo root. With
1068
+ // cwd=/repo/src, `git add a.js` stages src/a.js — reporting `a.js` made task-scope and
1069
+ // protected-file gates evaluate the wrong path, so a protected src/a.js edit could pass.
1070
+ // Track a leading `cd` too, since `cd src && git add a.js` is the common shape.
1071
+ // Returns null when the working directory cannot be determined. A `cd` whose target is a
1072
+ // glob or variable makes every later relative pathspec unresolvable — resolving it against
1073
+ // the ORIGINAL directory would silently produce a wrong path, which is exactly the
1074
+ // guess-instead-of-widen mistake that made pathspec magic evade scope checks. Callers treat
1075
+ // null as "unknown" and fall back to broad.
1076
+ function effectiveCommandCwd(command, toolInput) {
1077
+ let cwd = String(toolInput?.cwd || toolInput?.repoPath || process.cwd());
1078
+ const segments = String(command || '').split(/\r?\n|&&|\|\||[;|&]/);
1079
+ for (const segment of segments) {
1080
+ // Parsed without a regex: /^cd\s+(?:--\s+)?(.+)$/ has adjacent \s+ groups that backtrack
1081
+ // polynomially on input like `cd\t\t\t…` (js/polynomial-redos). The command comes
1082
+ // straight off the pending tool call, so stalling here stalls the gate.
1083
+ const trimmed = segment.trim();
1084
+ if (!trimmed.startsWith('cd')) break; // only a LEADING cd chain applies
1085
+ const afterCd = trimmed.slice(2);
1086
+ if (afterCd && !/^[ \t]/.test(afterCd)) break; // `cdfoo` is not `cd`
1087
+ let argText = afterCd.trim();
1088
+ if (argText === '--') argText = '';
1089
+ else if (argText.startsWith('--') && /^[ \t]/.test(argText.slice(2))) argText = argText.slice(2).trim();
1090
+ const target = tokenizeShellWords(argText)[0];
1091
+ if (!target) break; // bare `cd` -> home; leave scope resolution alone
1092
+ if (/[*?$`]|^~/.test(target)) return null;
1093
+ cwd = path.resolve(cwd, target);
1094
+ }
1095
+ return cwd;
1096
+ }
1097
+
1098
+ // Keep a tree-derived file only when it falls inside one of the declared pathspecs, so a
1099
+ // directory pathspec (`git add src/`) still reports the files under it and nothing else.
1100
+ function isExistingDirectory(relPath, repoRoot) {
1101
+ if (!repoRoot) return false;
1102
+ try {
1103
+ return fs.statSync(path.join(repoRoot, relPath)).isDirectory();
1104
+ } catch {
1105
+ return false;
1106
+ }
1107
+ }
1108
+
1109
+ function isUnderPathspec(relPath, pathspecs) {
1110
+ return pathspecs.some((spec) => relPath === spec || relPath.startsWith(`${spec}/`));
1111
+ }
1112
+
1113
+ // Linear trailing-slash strip. A regex like /\/+$/ backtracks polynomially on a long run of
1114
+ // slashes (js/polynomial-redos), and the pathspec comes straight off the pending command —
1115
+ // stalling the gate is itself a way to defeat it.
1116
+ function stripTrailingSlashes(value) {
1117
+ let end = value.length;
1118
+ while (end > 0 && value[end - 1] === '/') end -= 1;
1119
+ return value.slice(0, end);
1120
+ }
1121
+
1122
+ function applyPathspecScope(files, treeFiles, pathspec, repoRoot, commandCwd) {
1123
+ if (pathspec.broad) {
1124
+ for (const filePath of treeFiles) files.add(normalizePosix(filePath));
1125
+ return;
1126
+ }
1127
+ const specs = pathspec.paths
1128
+ .map((entry) => (path.isAbsolute(entry) ? entry : path.resolve(commandCwd || repoRoot || '.', entry)))
1129
+ .map((entry) => toRepoRelativePath(entry, repoRoot))
1130
+ .filter(Boolean)
1131
+ .map((entry) => stripTrailingSlashes(normalizePosix(entry)));
1132
+ if (!specs.length) {
1133
+ for (const filePath of treeFiles) files.add(normalizePosix(filePath));
1134
+ return;
1135
+ }
1136
+ const matchedSpecs = new Set();
1137
+ for (const filePath of treeFiles) {
1138
+ const normalized = normalizePosix(filePath);
1139
+ for (const spec of specs) {
1140
+ if (normalized === spec || normalized.startsWith(`${spec}/`)) {
1141
+ files.add(normalized);
1142
+ matchedSpecs.add(spec);
1143
+ }
1144
+ }
1145
+ }
1146
+ // An explicitly named file is in scope even when the tree scan does not list it (e.g. it
1147
+ // is already staged). A directory spec that matched tree files is not itself a file, and
1148
+ // an unmatched directory contributes nothing.
1149
+ for (const spec of matchedSpecs.size === specs.length ? [] : specs) {
1150
+ if (matchedSpecs.has(spec)) continue;
1151
+ if (isExistingDirectory(spec, repoRoot)) continue;
1152
+ files.add(spec);
1153
+ }
1154
+ }
1155
+
1156
+ // Git accepts global options BETWEEN `git` and the subcommand: `git -C <dir> push`,
1157
+ // `git -c k=v clean`, `git --git-dir=<p> reset`. Every command-pattern gate here is written
1158
+ // against the plain `git <subcommand>` form, so inserting one option was enough to walk past
1159
+ // force-push, git-reset-hard, git-clean-force and the local-only gates entirely — and to make
1160
+ // extractAffectedFiles report nothing, which silently disarms the task-scope and
1161
+ // protected-file gates too. Canonicalize the options away so the same command is recognised
1162
+ // however it is spelled. Callers match the ORIGINAL and the canonical form, so this can only
1163
+ // ever add a match, never remove one.
1164
+ const GIT_GLOBAL_OPTION_AFTER_GIT = /\bgit\s+(?:-[cC]\s+\S+|--(?:git-dir|work-tree|namespace|exec-path|super-prefix)(?:=\S+|\s+\S+)|--(?:paginate|no-pager|bare|literal-pathspecs|glob-pathspecs|noglob-pathspecs|icase-pathspecs|no-replace-objects|no-optional-locks)|-[pP])\s+/g;
1165
+
1166
+ function canonicalizeGitCommand(command) {
1167
+ let out = String(command || '');
1168
+ // Bounded: a crafted command with many stacked options must not loop unboundedly.
1169
+ for (let i = 0; i < 12; i += 1) {
1170
+ const next = out.replace(GIT_GLOBAL_OPTION_AFTER_GIT, 'git ');
1171
+ if (next === out) break;
1172
+ out = next;
1173
+ }
1174
+ return out;
1175
+ }
1176
+
1177
+ // The catastrophic gate patterns anchor the command position as `(?:^|[;&|]\s*)`, i.e. the
1178
+ // command must sit at the very start of the string or immediately after ; & |. That anchor
1179
+ // exists to avoid matching a command mentioned inside a quoted string, but it is far too
1180
+ // narrow: it does not recognise a command on a NEW LINE, nor any of the ordinary ways a
1181
+ // binary gets invoked. Each of the following defeated git-reset-hard and git-clean-force on
1182
+ // shipped main — no gate matched at all:
1183
+ //
1184
+ // sudo git reset --hard GIT_DIR=… git reset --hard /usr/bin/git reset --hard
1185
+ // command git reset --hard "git" reset --hard \git reset --hard
1186
+ // echo hi\ngit reset --hard
1187
+ //
1188
+ // Rather than complicate every gate's regex, canonicalize the command POSITION: split on
1189
+ // separators (including newlines), strip env-assignment prefixes, wrapper binaries and any
1190
+ // directory/quoting on the binary token, then rejoin with `; ` so the existing anchor sees a
1191
+ // clean command. Callers match the original AND the canonical form, so this only ever adds.
1192
+ const COMMAND_WRAPPERS = new Set([
1193
+ 'sudo', 'doas', 'command', 'builtin', 'exec', 'nohup', 'time', 'env',
1194
+ 'nice', 'ionice', 'setsid', 'stdbuf', 'xargs',
1195
+ ]);
1196
+ const ENV_ASSIGNMENT_PREFIX = /^[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s]*)\s+/;
1197
+ const WRAPPER_HEAD = /^([A-Za-z_][\w.-]*)\s+/;
1198
+
1199
+ function canonicalizeSegmentHead(segment) {
1200
+ let text = String(segment || '').trim();
1201
+ for (let i = 0; i < 12; i += 1) {
1202
+ const before = text;
1203
+ text = text.replace(ENV_ASSIGNMENT_PREFIX, '');
1204
+ const wrapper = text.match(WRAPPER_HEAD);
1205
+ if (wrapper && COMMAND_WRAPPERS.has(wrapper[1].toLowerCase())) {
1206
+ text = text.slice(wrapper[0].length);
1207
+ }
1208
+ if (text === before) break;
1209
+ }
1210
+ // Unwrap quoting/escaping on the binary token: "git" / 'git' / \git
1211
+ text = text.replace(/^\\(?=[A-Za-z_])/, '');
1212
+ text = text.replace(/^(['"])([^'"\s]+)\1/, '$2');
1213
+ // Drop a leading directory on the binary token: /usr/bin/git, ./bin/git, ../git
1214
+ text = text.replace(/^((?:\.{1,2})?(?:\/[^\s/]+)*\/)([^\s/]+)/, '$2');
1215
+ return text;
1216
+ }
1217
+
1218
+ function canonicalizeCommandPositions(command) {
1219
+ const text = String(command || '');
1220
+ if (!text) return '';
1221
+ return text
1222
+ .split(/\r?\n|&&|\|\||[;|&]/)
1223
+ .map((segment) => canonicalizeSegmentHead(segment))
1224
+ .filter((segment) => segment.length > 0)
1225
+ .join('; ');
1226
+ }
1227
+
1228
+ // Full canonical form used for gate matching.
1229
+ function canonicalizeCommandForGates(command) {
1230
+ return canonicalizeGitCommand(canonicalizeCommandPositions(command));
1231
+ }
1232
+
1233
+ // Some gates anchor with a BARE `^` (local-only-git-writes, task-scope-required,
1234
+ // branch-governance-required, release-readiness-required) rather than the
1235
+ // `(?:^|[;&|]\s*)` form. A bare `^` only ever matches the FIRST command in the string, so
1236
+ // `echo hi && git commit -m x` slipped past while `git commit -m x` was denied — and
1237
+ // chaining is how agents normally work. Offer each canonicalized SEGMENT as its own
1238
+ // candidate so a `^` anchor sees every command in the chain, not just the head.
1239
+ //
1240
+ // This stays additive: unanchored patterns already match anywhere, so per-segment testing
1241
+ // adds nothing for them, and a `^` pattern matching a later segment is exactly the gate's
1242
+ // intent. A command merely quoted inside another (`echo "git commit"`) is unaffected,
1243
+ // because the segment head is still `echo`.
1244
+ function gateMatchCandidates(matchText) {
1245
+ const canonical = canonicalizeCommandForGates(matchText);
1246
+ const candidates = [matchText];
1247
+ if (canonical && canonical !== matchText) candidates.push(canonical);
1248
+ for (const segment of canonical.split('; ')) {
1249
+ const trimmed = segment.trim();
1250
+ if (trimmed && trimmed !== canonical) candidates.push(trimmed);
1251
+ }
1252
+ return candidates;
1253
+ }
1254
+
1255
+ function patternMatchesCommand(regex, matchText) {
1256
+ return gateMatchCandidates(matchText).some((candidate) => regex.test(candidate));
1257
+ }
1258
+
951
1259
  function extractAffectedFiles(toolName, toolInput = {}) {
952
1260
  const repoRoot = resolveRepoRoot(toolInput);
953
1261
  const files = new Set(collectInlineAffectedFiles(toolInput, repoRoot));
954
- const command = String(toolInput.command || '');
1262
+ // Full canonicalization, not just the git-option pass: `"git" add .` and `sudo git add .`
1263
+ // otherwise fail the `\bgit\s+add\b` probes below and yield ZERO affected files, which in
1264
+ // turn makes the scope gates (task-scope-required, protected-file-approval-required) find
1265
+ // no violation and fall through — the file-list half of the same bypass.
1266
+ const command = canonicalizeCommandForGates(String(toolInput.command || ''));
1267
+
1268
+ const commandCwd = effectiveCommandCwd(command, toolInput);
1269
+ // An unresolvable `cd` makes every relative pathspec meaningless; widen rather than guess.
1270
+ const cwdUnknown = commandCwd === null;
955
1271
 
956
1272
  if (toolName === 'Bash' && repoRoot && command) {
957
1273
  if (/\bgit\s+commit\b/i.test(command)) {
958
- for (const filePath of safeExecFileLines('git', ['diff', '--cached', '--name-only'], repoRoot)) {
959
- files.add(normalizePosix(filePath));
960
- }
1274
+ // For commit only an explicit `-- <pathspec>` narrows the staged set; bare arguments
1275
+ // after `git commit` are almost always flag values (`-m "msg"`), so anything else
1276
+ // keeps the staged-diff behaviour.
1277
+ const commitSpec = /\bgit\s+commit\b[^;|&\n]*?\s--\s/i.test(command)
1278
+ ? parseGitPathspec(command, 'commit', { separatorOnly: true })
1279
+ : { broad: true, paths: [] };
1280
+ // `git commit -- <pathspec>` commits tracked files straight from the WORKING TREE, not
1281
+ // only what is staged. Filtering the cached diff alone dropped exactly those files, so
1282
+ // scope and protected-file gates missed changes the commit really carries.
1283
+ const candidates = commitSpec.broad
1284
+ ? safeExecFileLines('git', ['diff', '--cached', '--name-only'], repoRoot)
1285
+ : [
1286
+ ...safeExecFileLines('git', ['diff', '--cached', '--name-only'], repoRoot),
1287
+ ...safeExecFileLines('git', ['diff', '--name-only'], repoRoot),
1288
+ ];
1289
+ applyPathspecScope(files, candidates, cwdUnknown ? { broad: true, paths: [] } : commitSpec, repoRoot, commandCwd);
961
1290
  }
962
1291
 
963
1292
  if (/\bgit\s+add\b/i.test(command)) {
964
- for (const filePath of safeExecFileLines('git', ['diff', '--name-only'], repoRoot)) {
965
- files.add(normalizePosix(filePath));
966
- }
967
- for (const filePath of safeExecFileLines('git', ['ls-files', '--others', '--exclude-standard'], repoRoot)) {
968
- files.add(normalizePosix(filePath));
969
- }
1293
+ const treeFiles = [
1294
+ ...safeExecFileLines('git', ['diff', '--name-only'], repoRoot),
1295
+ ...safeExecFileLines('git', ['ls-files', '--others', '--exclude-standard'], repoRoot),
1296
+ ];
1297
+ const addSpec = cwdUnknown ? { broad: true, paths: [] } : parseGitPathspec(command, 'add');
1298
+ applyPathspecScope(files, treeFiles, addSpec, repoRoot, commandCwd);
970
1299
  }
971
1300
 
972
1301
  if (/\bgit\s+push\b/i.test(command) || /\bgh\s+pr\s+(?:create|merge)\b/i.test(command) || isGhApiPrCreateCommand(command)) {
@@ -1331,9 +1660,36 @@ function isReadOnlyObservabilityTool(toolName) {
1331
1660
  }
1332
1661
 
1333
1662
  function evaluatePendingPrThreadResolutionGate(toolName, toolInput = {}) {
1334
- if (!hasAction(PR_THREAD_RESOLUTION_ACTION)) return null;
1663
+ const pendingEntry = loadSessionActions()[PR_THREAD_RESOLUTION_ACTION];
1664
+ if (!pendingEntry) return null;
1665
+
1666
+ // Scope to the repo that actually committed. Session-actions state lives in a
1667
+ // single global file (~/.thumbgate/session-actions.json), shared by every repo
1668
+ // on the machine. Without this check, a commit in repo A permanently locks out
1669
+ // every tool call in an unrelated repo B's session until the 1-hour TTL expires
1670
+ // (verified 2026-07-24: a commit in one repo/worktree blocked every Bash/Read/
1671
+ // Skill/ToolSearch call in a completely unrelated repo's session).
1672
+ const trackedRepoRoot = pendingEntry.metadata && pendingEntry.metadata.repoRoot;
1673
+ if (trackedRepoRoot) {
1674
+ const currentRepoRoot = resolveRepoRoot(toolInput);
1675
+ if (currentRepoRoot && currentRepoRoot !== trackedRepoRoot) return null;
1676
+ }
1677
+
1335
1678
  if (isThreadResolutionSatisfied()) return null;
1336
1679
  if (isReadOnlyObservabilityTool(toolName)) return null;
1680
+ // Evidence actions (gh pr view/checks/status, gh api .../reviewThreads, git
1681
+ // status/diff/show, the satisfy_gate/track_action tools themselves) are exempt
1682
+ // from being blocked so an agent can actually gather evidence and call
1683
+ // satisfy_gate — but running them must NOT itself satisfy the gate. This is a
1684
+ // PreToolUse hook: it fires before the command executes, so at this point the
1685
+ // command hasn't run, could still fail, or could return UNFAVORABLE evidence
1686
+ // (N unresolved threads). Auto-satisfying on the mere shape of the request —
1687
+ // as an earlier version of this fix did — let `git status` (which proves
1688
+ // nothing about thread resolution) or a `gh pr view` that later errors clear
1689
+ // a critical gate before any real verification happened (caught in review,
1690
+ // PR #3030). The only sound way to clear this gate is the explicit,
1691
+ // agent-asserted `satisfy_gate` tool call, which records real evidence via
1692
+ // satisfyCondition() — never inferred from a pre-execution command guess.
1337
1693
  if (isThreadResolutionEvidenceAction(toolName, toolInput)) return null;
1338
1694
 
1339
1695
  const message = 'A git commit was made on a PR branch. Verify review threads are resolved before the next tool call.';
@@ -1344,7 +1700,7 @@ function evaluatePendingPrThreadResolutionGate(toolName, toolInput = {}) {
1344
1700
  severity: 'critical',
1345
1701
  reasoning: [
1346
1702
  `Tracked action ${PR_THREAD_RESOLUTION_ACTION} is pending`,
1347
- 'Satisfy pr_threads_checked or thread_resolution_verified with evidence before continuing',
1703
+ 'Check review threads (e.g. gh pr view --json reviewThreads), then call the satisfy_gate tool with gateId="pr_threads_checked" and the evidence running a check command alone does not clear this gate',
1348
1704
  ],
1349
1705
  };
1350
1706
  }
@@ -1607,7 +1963,11 @@ function buildProtectedApprovalViolation(protectedGlobs, approvals, affectedFile
1607
1963
  }
1608
1964
 
1609
1965
  function buildBranchGovernanceViolation(governanceState, toolInput = {}, affectedFiles = [], repoRoot = null, requireReleaseReadiness = false) {
1610
- const command = String(toolInput.command || '').trim();
1966
+ // Canonicalized: this helper runs its OWN command analysis downstream of the gate's
1967
+ // pattern test, so passing the raw text let `"npm" publish` / `sudo gh release create`
1968
+ // through even once the pattern matched. Canonicalizing here keeps that second analysis
1969
+ // consistent with the first.
1970
+ const command = canonicalizeCommandForGates(String(toolInput.command || '').trim());
1611
1971
  if (!command) return null;
1612
1972
 
1613
1973
  const integrity = evaluateOperationalIntegrity({
@@ -1861,7 +2221,11 @@ function matchGate(gate, toolName, toolInput = {}) {
1861
2221
  if (gate.pattern) {
1862
2222
  try {
1863
2223
  const regex = new RegExp(gate.pattern);
1864
- if (!regex.test(matchText)) return { matched: false, matchText, affectedFiles };
2224
+ // Match the original text or its git-canonical form, so `git -C <dir> push --force`
2225
+ // is caught by the same pattern as `git push --force`.
2226
+ if (!patternMatchesCommand(regex, matchText)) {
2227
+ return { matched: false, matchText, affectedFiles };
2228
+ }
1865
2229
  if (gate.id === 'permission-change-approval' && isSafeLocalCredentialHardeningCommand(toolName, toolInput)) {
1866
2230
  return { matched: false, matchText, affectedFiles };
1867
2231
  }
@@ -1975,7 +2339,8 @@ function matchSelfProtectHardFloor(gate, toolName, toolInput = {}) {
1975
2339
  if (!Array.isArray(gate.toolNames) || !gate.toolNames.includes(toolName)) return null;
1976
2340
  if (!matchText || !gate.pattern) return null;
1977
2341
  try {
1978
- if (!new RegExp(gate.pattern).test(matchText)) return null;
2342
+ const regex = new RegExp(gate.pattern);
2343
+ if (!patternMatchesCommand(regex, matchText)) return null;
1979
2344
  } catch {
1980
2345
  return null;
1981
2346
  }
@@ -2109,12 +2474,18 @@ function evaluateMemoryGuard(toolName, toolInput = {}) {
2109
2474
  return null;
2110
2475
  }
2111
2476
 
2477
+ // The memory guard keyword-matches against this string. The false positives that motivated
2478
+ // a cap here came from the JSON envelope's own KEY names polluting the haystack, which is
2479
+ // fixed at the matcher (buildMatchHaystack). Truncating the file list instead silently
2480
+ // dropped action targets: for a broad action, a guard whose keywords appear only in a later
2481
+ // filename could no longer match, so a learned prevention rule was bypassable purely by
2482
+ // filename ordering. Keep every target and bound the SIZE instead.
2112
2483
  const serializedInput = JSON.stringify({
2113
2484
  toolName,
2114
2485
  command: toolInput.command || null,
2115
2486
  filePath: toolInput.file_path || toolInput.path || null,
2116
2487
  affectedFiles,
2117
- });
2488
+ }).slice(0, MEMORY_GUARD_MAX_SERIALIZED_CHARS);
2118
2489
  // Claw/hybrid support: pass context if agent provides claw metadata (for EnterpriseClaw/OpenShell/Perplexity hybrid agents)
2119
2490
  let guard;
2120
2491
  if (toolInput && (toolInput.clawContext || toolInput._claw || toolInput.hybridRoute || toolInput.agentId)) {
@@ -2253,7 +2624,7 @@ function isAutonomousRun() {
2253
2624
  return raw === '1' || raw === 'true';
2254
2625
  }
2255
2626
 
2256
- async function evaluateGatesAsync(toolName, toolInput, configPath) {
2627
+ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
2257
2628
  let config;
2258
2629
  try {
2259
2630
  let harnessPath;
@@ -2492,7 +2863,7 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
2492
2863
  return null;
2493
2864
  }
2494
2865
 
2495
- function evaluateGates(toolName, toolInput, configPath) {
2866
+ function evaluateGatesInner(toolName, toolInput, configPath) {
2496
2867
  let config;
2497
2868
  try {
2498
2869
  let harnessPath;
@@ -3292,6 +3663,42 @@ function run(input) {
3292
3663
 
3293
3664
  }
3294
3665
 
3666
+
3667
+ // A command can be spelled many ways without changing what it does: `sudo git …`,
3668
+ // `"git" …`, `/usr/bin/git …`, `GIT_DIR=… git …`, a chained `echo hi && git …`, or a git
3669
+ // global option before the subcommand. Roughly fifteen helpers below read
3670
+ // `toolInput.command` and run their own analysis on it, so patching each one individually
3671
+ // is how a spelling gets missed — that happened twice while fixing this.
3672
+ //
3673
+ // Instead: evaluate normally, and ONLY IF nothing matched, evaluate once more against the
3674
+ // canonicalized command. Every helper is covered without touching any of them, and it is
3675
+ // strictly additive — a command that already matched never reaches the second pass, so no
3676
+ // existing verdict changes and no stat is recorded twice.
3677
+ function canonicalRetryInput(toolName, toolInput) {
3678
+ if (toolName !== 'Bash') return null;
3679
+ const original = String((toolInput && toolInput.command) || '');
3680
+ if (!original) return null;
3681
+ const canonical = canonicalizeCommandForGates(original);
3682
+ if (!canonical || canonical === original) return null;
3683
+ return { ...toolInput, command: canonical, originalCommand: original };
3684
+ }
3685
+
3686
+ async function evaluateGatesAsync(toolName, toolInput, configPath) {
3687
+ const direct = await evaluateGatesAsyncInner(toolName, toolInput, configPath);
3688
+ if (direct) return direct;
3689
+ const retry = canonicalRetryInput(toolName, toolInput);
3690
+ if (!retry) return null;
3691
+ return evaluateGatesAsyncInner(toolName, retry, configPath);
3692
+ }
3693
+
3694
+ function evaluateGates(toolName, toolInput, configPath) {
3695
+ const direct = evaluateGatesInner(toolName, toolInput, configPath);
3696
+ if (direct) return direct;
3697
+ const retry = canonicalRetryInput(toolName, toolInput);
3698
+ if (!retry) return null;
3699
+ return evaluateGatesInner(toolName, retry, configPath);
3700
+ }
3701
+
3295
3702
  // ---------------------------------------------------------------------------
3296
3703
  // Session action tracking and claim verification
3297
3704
  // ---------------------------------------------------------------------------
@@ -3578,6 +3985,12 @@ module.exports = {
3578
3985
  matchesGate,
3579
3986
  evaluateGates,
3580
3987
  evaluateGatesAsync,
3988
+ extractAffectedFiles,
3989
+ parseGitPathspec,
3990
+ canonicalizeGitCommand,
3991
+ canonicalizeCommandForGates,
3992
+ canonicalizeCommandPositions,
3993
+ patternMatchesCommand,
3581
3994
  isAutonomousRun,
3582
3995
  computeExecutableHash,
3583
3996
  formatOutput,