thumbgate 0.9.13 → 1.0.0

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 (70) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.well-known/mcp/server-card.json +1 -1
  4. package/README.md +6 -3
  5. package/adapters/README.md +1 -1
  6. package/adapters/chatgpt/openapi.yaml +105 -0
  7. package/adapters/claude/.mcp.json +2 -2
  8. package/adapters/codex/config.toml +2 -2
  9. package/adapters/forge/forge.yaml +28 -0
  10. package/adapters/mcp/server-stdio.js +32 -1
  11. package/adapters/opencode/opencode.json +1 -1
  12. package/bin/cli.js +53 -3
  13. package/config/mcp-allowlists.json +10 -0
  14. package/openapi/openapi.yaml +105 -0
  15. package/package.json +4 -4
  16. package/plugins/amp-skill/INSTALL.md +3 -4
  17. package/plugins/amp-skill/SKILL.md +0 -1
  18. package/plugins/claude-codex-bridge/.claude-plugin/plugin.json +1 -1
  19. package/plugins/claude-codex-bridge/.mcp.json +1 -1
  20. package/plugins/claude-skill/INSTALL.md +1 -2
  21. package/plugins/codex-profile/.codex-plugin/plugin.json +1 -1
  22. package/plugins/codex-profile/.mcp.json +1 -1
  23. package/plugins/codex-profile/INSTALL.md +1 -1
  24. package/plugins/codex-profile/README.md +1 -1
  25. package/plugins/cursor-marketplace/.cursor-plugin/plugin.json +1 -1
  26. package/plugins/opencode-profile/INSTALL.md +1 -1
  27. package/public/blog.html +1 -0
  28. package/public/dashboard.html +1 -1
  29. package/public/guide.html +1 -1
  30. package/public/index.html +29 -5
  31. package/public/learn/agent-harness-pattern.html +1 -1
  32. package/public/learn/ai-agent-persistent-memory.html +1 -1
  33. package/public/learn/mcp-pre-action-gates-explained.html +1 -1
  34. package/public/learn/stop-ai-agent-force-push.html +1 -1
  35. package/public/learn/vibe-coding-safety-net.html +1 -1
  36. package/public/learn.html +62 -1
  37. package/public/lessons.html +1 -1
  38. package/public/pro.html +1 -1
  39. package/scripts/__pycache__/train_from_feedback.cpython-312.pyc +0 -0
  40. package/scripts/agent-security-hardening.js +4 -4
  41. package/scripts/async-job-runner.js +84 -24
  42. package/scripts/auto-wire-hooks.js +59 -1
  43. package/scripts/context-manager.js +330 -0
  44. package/scripts/dashboard.js +1 -1
  45. package/scripts/distribution-surfaces.js +12 -0
  46. package/scripts/ensure-repo-bootstrap.js +15 -14
  47. package/scripts/feedback-history-distiller.js +7 -1
  48. package/scripts/feedback-loop.js +10 -4
  49. package/scripts/feedback-paths.js +142 -10
  50. package/scripts/feedback-root-consolidator.js +18 -4
  51. package/scripts/gates-engine.js +96 -10
  52. package/scripts/hook-auto-capture.sh +1 -1
  53. package/scripts/hosted-job-launcher.js +260 -0
  54. package/scripts/managed-dpo-export.js +91 -0
  55. package/scripts/obsidian-export.js +0 -1
  56. package/scripts/operational-integrity.js +50 -7
  57. package/scripts/post-everywhere.js +10 -0
  58. package/scripts/prove-lancedb.js +62 -4
  59. package/scripts/publish-decision.js +16 -0
  60. package/scripts/self-healing-check.js +6 -1
  61. package/scripts/seo-gsd.js +217 -4
  62. package/scripts/social-analytics/load-env.js +33 -2
  63. package/scripts/social-analytics/store.js +200 -2
  64. package/scripts/statusline-cache-path.js +9 -6
  65. package/scripts/sync-version.js +18 -11
  66. package/scripts/tool-registry.js +37 -0
  67. package/scripts/train_from_feedback.py +0 -4
  68. package/scripts/workflow-sentinel.js +793 -0
  69. package/src/api/server.js +297 -38
  70. /package/scripts/{rlhf_session_start.sh → thumbgate_session_start.sh} +0 -0
@@ -5,12 +5,13 @@ const fs = require('fs');
5
5
  const path = require('path');
6
6
 
7
7
  const REPO_ROOT = path.resolve(process.argv[2] || process.cwd());
8
- const RLHF_ENTRY = {
8
+ const THUMBGATE_ENTRY = {
9
9
  command: 'npx',
10
10
  args: ['-y', 'thumbgate@latest', 'serve'],
11
11
  };
12
- const LEGACY_SERVER_NAMES = ['thumbgate', 'rlhf_feedback_loop'];
13
- const INFO_EXCLUDE_ENTRIES = ['.rlhf/', '.thumbgate/', '.mcp.json'];
12
+ const MCP_SERVER_KEY = 'thumbgate';
13
+ const LEGACY_SERVER_NAMES = ['rlhf', 'mcp-memory-gateway', 'rlhf_feedback_loop'];
14
+ const INFO_EXCLUDE_ENTRIES = ['.thumbgate/', '.mcp.json'];
14
15
 
15
16
  function readJson(filePath) {
16
17
  try {
@@ -36,11 +37,11 @@ function writeJsonIfChanged(filePath, value) {
36
37
  return true;
37
38
  }
38
39
 
39
- function mergeRlhfEntry(entry = {}) {
40
+ function mergeThumbgateEntry(entry = {}) {
40
41
  return {
41
42
  ...entry,
42
- command: RLHF_ENTRY.command,
43
- args: RLHF_ENTRY.args.slice(),
43
+ command: THUMBGATE_ENTRY.command,
44
+ args: THUMBGATE_ENTRY.args.slice(),
44
45
  };
45
46
  }
46
47
 
@@ -49,7 +50,7 @@ function ensureMcpJson(repoRoot) {
49
50
  const existing = readJson(filePath);
50
51
  const config = existing && typeof existing === 'object' ? existing : {};
51
52
  config.mcpServers = config.mcpServers && typeof config.mcpServers === 'object' ? config.mcpServers : {};
52
- config.mcpServers.rlhf = mergeRlhfEntry(config.mcpServers.rlhf);
53
+ config.mcpServers[MCP_SERVER_KEY] = mergeThumbgateEntry(config.mcpServers[MCP_SERVER_KEY]);
53
54
  for (const legacyName of LEGACY_SERVER_NAMES) {
54
55
  delete config.mcpServers[legacyName];
55
56
  }
@@ -63,13 +64,13 @@ function ensureClaudeSettings(repoRoot) {
63
64
  return false;
64
65
  }
65
66
  const hasRelevantServer =
66
- Boolean(existing.mcpServers && existing.mcpServers.rlhf) ||
67
+ Boolean(existing.mcpServers && existing.mcpServers[MCP_SERVER_KEY]) ||
67
68
  LEGACY_SERVER_NAMES.some((name) => Boolean(existing.mcpServers && existing.mcpServers[name]));
68
69
  if (!hasRelevantServer) {
69
70
  return false;
70
71
  }
71
72
  existing.mcpServers = existing.mcpServers && typeof existing.mcpServers === 'object' ? existing.mcpServers : {};
72
- existing.mcpServers.rlhf = mergeRlhfEntry(existing.mcpServers.rlhf);
73
+ existing.mcpServers[MCP_SERVER_KEY] = mergeThumbgateEntry(existing.mcpServers[MCP_SERVER_KEY]);
73
74
  for (const legacyName of LEGACY_SERVER_NAMES) {
74
75
  delete existing.mcpServers[legacyName];
75
76
  }
@@ -106,19 +107,19 @@ function ensureInfoExclude(repoRoot) {
106
107
  return true;
107
108
  }
108
109
 
109
- function ensureRlhfDir(repoRoot) {
110
- const rlhfDir = path.join(repoRoot, '.rlhf');
111
- if (fs.existsSync(rlhfDir)) {
110
+ function ensureThumbgateDir(repoRoot) {
111
+ const thumbgateDir = path.join(repoRoot, '.thumbgate');
112
+ if (fs.existsSync(thumbgateDir)) {
112
113
  return false;
113
114
  }
114
- fs.mkdirSync(rlhfDir, { recursive: true });
115
+ fs.mkdirSync(thumbgateDir, { recursive: true });
115
116
  return true;
116
117
  }
117
118
 
118
119
  function main() {
119
120
  const results = {
120
121
  repoRoot: REPO_ROOT,
121
- createdRlhfDir: ensureRlhfDir(REPO_ROOT),
122
+ createdThumbgateDir: ensureThumbgateDir(REPO_ROOT),
122
123
  updatedMcpJson: ensureMcpJson(REPO_ROOT),
123
124
  updatedClaudeSettings: ensureClaudeSettings(REPO_ROOT),
124
125
  updatedInfoExclude: ensureInfoExclude(REPO_ROOT),
@@ -79,7 +79,13 @@ function readJsonlTail(filePath, limit = DEFAULT_HISTORY_LIMIT) {
79
79
  }
80
80
 
81
81
  function resolveFeedbackDir(feedbackDir) {
82
- return resolveSharedFeedbackDir({ feedbackDir });
82
+ if (feedbackDir) {
83
+ return resolveSharedFeedbackDir({ feedbackDir });
84
+ }
85
+ const env = { ...process.env };
86
+ delete env.INIT_CWD;
87
+ delete env.PWD;
88
+ return resolveSharedFeedbackDir({ env });
83
89
  }
84
90
 
85
91
  function getConversationPaths(feedbackDir) {
@@ -146,8 +146,8 @@ function updateStatuslineWithLesson({ accepted, signal, memoryId, feedbackId, le
146
146
  } catch { /* statusline update is best-effort */ }
147
147
  }
148
148
 
149
- function getFeedbackPaths() {
150
- return resolveFeedbackPaths();
149
+ function getFeedbackPaths(options = {}) {
150
+ return resolveFeedbackPaths(options);
151
151
  }
152
152
 
153
153
  function getContextFsModule() {
@@ -1673,8 +1673,8 @@ function writePreventionRules(filePath, minOccurrences = 2) {
1673
1673
  return { path: outPath, markdown };
1674
1674
  }
1675
1675
 
1676
- function feedbackSummary(recentN = 20) {
1677
- const { FEEDBACK_LOG_PATH } = getFeedbackPaths();
1676
+ function feedbackSummary(recentN = 20, options = {}) {
1677
+ const { FEEDBACK_LOG_PATH } = getFeedbackPaths(options);
1678
1678
  const entries = readJSONL(FEEDBACK_LOG_PATH);
1679
1679
  if (entries.length === 0) {
1680
1680
  return '## Feedback Summary\nNo feedback recorded yet.';
@@ -1794,6 +1794,10 @@ function runTests() {
1794
1794
  const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'thumbgate-loop-test-'));
1795
1795
  const localFeedbackLog = path.join(tmpDir, 'feedback-log.jsonl');
1796
1796
  process.env.THUMBGATE_FEEDBACK_DIR = tmpDir;
1797
+ const savedInitCwd = process.env.INIT_CWD;
1798
+ process.env.INIT_CWD = savedInitCwd || process.cwd();
1799
+
1800
+ assert(getFeedbackPaths().FEEDBACK_DIR === tmpDir, 'explicit feedback dir wins over npm INIT_CWD');
1797
1801
 
1798
1802
  appendJSONL(localFeedbackLog, { signal: 'positive', tags: ['testing'], skill: 'verify' });
1799
1803
  appendJSONL(localFeedbackLog, { signal: 'negative', tags: ['testing'], skill: 'verify' });
@@ -1845,6 +1849,8 @@ function runTests() {
1845
1849
 
1846
1850
  fs.rmSync(tmpDir, { recursive: true, force: true });
1847
1851
  delete process.env.THUMBGATE_FEEDBACK_DIR;
1852
+ if (savedInitCwd === undefined) delete process.env.INIT_CWD;
1853
+ else process.env.INIT_CWD = savedInitCwd;
1848
1854
  console.log(`\nResults: ${passed} passed, ${failed} failed\n`);
1849
1855
  process.exit(failed > 0 ? 1 : 0);
1850
1856
  }
@@ -43,14 +43,139 @@ function dirExists(dirPath) {
43
43
  }
44
44
  }
45
45
 
46
+ function getHomeDir(options = {}) {
47
+ const env = options.env || process.env;
48
+ return options.home || env.HOME || env.USERPROFILE || HOME;
49
+ }
50
+
51
+ function normalizeDir(dirPath) {
52
+ if (!dirPath) return null;
53
+ try {
54
+ return path.resolve(String(dirPath));
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ function isWithinDir(candidate, parent) {
61
+ const normalizedCandidate = normalizeDir(candidate);
62
+ const normalizedParent = normalizeDir(parent);
63
+ if (!normalizedCandidate || !normalizedParent) return false;
64
+ const relative = path.relative(normalizedParent, normalizedCandidate);
65
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
66
+ }
67
+
68
+ function getRuntimeDir(options = {}) {
69
+ return path.join(getHomeDir(options), '.thumbgate', 'runtime');
70
+ }
71
+
72
+ function getActiveProjectStatePath(options = {}) {
73
+ return path.join(getRuntimeDir(options), 'active-project.json');
74
+ }
75
+
76
+ function isTransientProjectDir(dirPath, options = {}) {
77
+ const normalizedDir = normalizeDir(dirPath);
78
+ if (!normalizedDir) return true;
79
+ if (!dirExists(normalizedDir)) return true;
80
+
81
+ const runtimeDir = getRuntimeDir(options);
82
+ if (isWithinDir(normalizedDir, runtimeDir)) return true;
83
+
84
+ return normalizedDir.includes(`${path.sep}.npm${path.sep}_npx${path.sep}`)
85
+ || /thumbgate-published-cli-/i.test(normalizedDir);
86
+ }
87
+
88
+ function readActiveProjectState(options = {}) {
89
+ const statePath = getActiveProjectStatePath(options);
90
+ try {
91
+ const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8'));
92
+ if (!parsed || !parsed.projectDir) return null;
93
+ if (isTransientProjectDir(parsed.projectDir, options)) return null;
94
+ return {
95
+ ...parsed,
96
+ projectDir: normalizeDir(parsed.projectDir),
97
+ };
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ function writeActiveProjectState(projectDir, options = {}) {
104
+ const normalizedDir = normalizeDir(projectDir);
105
+ if (isTransientProjectDir(normalizedDir, options)) return null;
106
+
107
+ const payload = {
108
+ projectDir: normalizedDir,
109
+ projectName: path.basename(normalizedDir) || 'default',
110
+ updatedAt: new Date().toISOString(),
111
+ };
112
+
113
+ const statePath = getActiveProjectStatePath(options);
114
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
115
+ fs.writeFileSync(statePath, JSON.stringify(payload, null, 2));
116
+ return payload;
117
+ }
118
+
119
+ function resolveProjectDir(options = {}) {
120
+ const env = options.env || process.env;
121
+ const stored = options.includeStored === false ? null : readActiveProjectState(options);
122
+ const cwdCandidates = uniquePaths([
123
+ options.cwd,
124
+ env.PWD,
125
+ process.cwd(),
126
+ ]);
127
+ const isTransientExecution = cwdCandidates.length > 0
128
+ && cwdCandidates.every((candidate) => isTransientProjectDir(candidate, options));
129
+ const candidates = uniquePaths([
130
+ options.projectDir,
131
+ env.THUMBGATE_PROJECT_DIR,
132
+ env.CLAUDE_PROJECT_DIR,
133
+ isTransientExecution && stored && stored.projectDir,
134
+ env.INIT_CWD,
135
+ ...cwdCandidates,
136
+ !isTransientExecution && stored && stored.projectDir,
137
+ ]);
138
+
139
+ for (const candidate of candidates) {
140
+ if (!candidate) continue;
141
+ if (!isTransientProjectDir(candidate, options)) {
142
+ return normalizeDir(candidate);
143
+ }
144
+ }
145
+
146
+ return normalizeDir(options.cwd || env.PWD || PROJECT_ROOT) || PROJECT_ROOT;
147
+ }
148
+
46
149
  function getProjectName(cwd = process.cwd()) {
47
150
  return path.basename(cwd || PROJECT_ROOT) || 'default';
48
151
  }
49
152
 
153
+ function hasDirectProjectScope(options = {}) {
154
+ const env = options.env || process.env;
155
+ return Boolean(
156
+ options.explicitProjectDir
157
+ || env.THUMBGATE_PROJECT_DIR
158
+ || env.CLAUDE_PROJECT_DIR
159
+ );
160
+ }
161
+
162
+ function hasExplicitProjectScope(options = {}) {
163
+ return Boolean(hasDirectProjectScope(options) || readActiveProjectState(options));
164
+ }
165
+
50
166
  function getExplicitFeedbackDir(options = {}) {
51
167
  const env = options.env || process.env;
52
168
  if (options.feedbackDir) return options.feedbackDir;
53
- if (env.THUMBGATE_FEEDBACK_DIR) return env.THUMBGATE_FEEDBACK_DIR;
169
+ if (options.skipExplicitFeedbackDir) return null;
170
+ // A caller-provided feedback root should stay authoritative over stored
171
+ // active-project state so isolated CLI/test commands do not drift into a
172
+ // different project. Only direct project overrides suppress it.
173
+ if (env.THUMBGATE_FEEDBACK_DIR && !hasDirectProjectScope(options)) {
174
+ return env.THUMBGATE_FEEDBACK_DIR;
175
+ }
176
+ if (hasDirectProjectScope(options)) {
177
+ return null;
178
+ }
54
179
  if (env.RAILWAY_VOLUME_MOUNT_PATH) {
55
180
  return path.join(env.RAILWAY_VOLUME_MOUNT_PATH, 'feedback');
56
181
  }
@@ -58,30 +183,29 @@ function getExplicitFeedbackDir(options = {}) {
58
183
  }
59
184
 
60
185
  function getThumbgateFeedbackDir(options = {}) {
61
- const cwd = options.cwd || process.cwd();
62
- return path.join(cwd, '.thumbgate');
186
+ const projectDir = resolveProjectDir(options);
187
+ return path.join(projectDir, '.thumbgate');
63
188
  }
64
189
 
65
190
  function getFallbackFeedbackDir(options = {}) {
66
191
  const env = options.env || process.env;
67
192
  if (env._TEST_THUMBGATE_FALLBACK_FEEDBACK_DIR) return env._TEST_THUMBGATE_FALLBACK_FEEDBACK_DIR;
68
193
  if (env.THUMBGATE_FALLBACK_FEEDBACK_DIR) return env.THUMBGATE_FALLBACK_FEEDBACK_DIR;
69
- const cwd = options.cwd || process.cwd();
70
- return path.join(cwd, '.thumbgate-compat');
194
+ const projectDir = resolveProjectDir(options);
195
+ return path.join(projectDir, '.thumbgate-compat');
71
196
  }
72
197
 
73
198
  function getLegacyFeedbackDir(options = {}) {
74
199
  const env = options.env || process.env;
75
200
  if (env._TEST_LEGACY_FEEDBACK_DIR) return env._TEST_LEGACY_FEEDBACK_DIR;
76
201
  if (env.THUMBGATE_LEGACY_FEEDBACK_DIR) return env.THUMBGATE_LEGACY_FEEDBACK_DIR;
77
- const cwd = options.cwd || process.cwd();
78
- return path.join(cwd, '.claude', 'memory', 'feedback');
202
+ const projectDir = resolveProjectDir(options);
203
+ return path.join(projectDir, '.claude', 'memory', 'feedback');
79
204
  }
80
205
 
81
206
  function getGlobalFeedbackDir(options = {}) {
82
- const cwd = options.cwd || process.cwd();
83
- const home = options.home || HOME;
84
- return path.join(home, '.thumbgate', 'projects', getProjectName(cwd));
207
+ const projectDir = resolveProjectDir(options);
208
+ return path.join(getHomeDir(options), '.thumbgate', 'projects', getProjectName(projectDir));
85
209
  }
86
210
 
87
211
  function resolveFeedbackDir(options = {}) {
@@ -133,13 +257,21 @@ module.exports = {
133
257
  PROJECT_ROOT,
134
258
  HOME,
135
259
  buildFeedbackPathsFromDir,
260
+ getActiveProjectStatePath,
136
261
  getFeedbackPaths,
137
262
  getGlobalFeedbackDir,
263
+ getHomeDir,
138
264
  getLegacyFeedbackDir,
139
265
  getFallbackFeedbackDir,
266
+ getRuntimeDir,
140
267
  getThumbgateFeedbackDir,
268
+ hasDirectProjectScope,
269
+ hasExplicitProjectScope,
270
+ readActiveProjectState,
141
271
  listFallbackFeedbackDirs,
142
272
  listFeedbackArtifactPaths,
273
+ resolveProjectDir,
143
274
  resolveFallbackArtifactPath,
144
275
  resolveFeedbackDir,
276
+ writeActiveProjectState,
145
277
  };
@@ -29,6 +29,19 @@ const CONSOLIDATED_ARTIFACTS = [
29
29
  'workflow-sprint-leads.jsonl',
30
30
  ];
31
31
 
32
+ function getScopedProjectOptions(options = {}) {
33
+ if (options.feedbackDir) return options;
34
+
35
+ const projectDir = options.projectDir || options.cwd;
36
+ if (!projectDir) return options;
37
+
38
+ return {
39
+ ...options,
40
+ projectDir,
41
+ explicitProjectDir: true,
42
+ };
43
+ }
44
+
32
45
  function ensureParentDir(filePath) {
33
46
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
34
47
  }
@@ -194,11 +207,11 @@ function consolidateArtifact(fileName, options = {}) {
194
207
  }
195
208
 
196
209
  function consolidateFeedbackRoot(options = {}) {
197
- const feedbackDir = options.feedbackDir
198
- || process.env.THUMBGATE_FEEDBACK_DIR
199
- || getThumbgateFeedbackDir(options);
210
+ const scopedOptions = getScopedProjectOptions(options);
211
+ const feedbackDir = scopedOptions.feedbackDir
212
+ || getThumbgateFeedbackDir(scopedOptions);
200
213
  const artifacts = CONSOLIDATED_ARTIFACTS.map((fileName) => consolidateArtifact(fileName, {
201
- ...options,
214
+ ...scopedOptions,
202
215
  feedbackDir,
203
216
  }));
204
217
  const sourceRoots = Array.from(new Set(
@@ -229,6 +242,7 @@ module.exports = {
229
242
  consolidateArtifact,
230
243
  consolidateFeedbackRoot,
231
244
  dedupeJsonlRows,
245
+ getScopedProjectOptions,
232
246
  mergeCheckoutSessionsPayloads,
233
247
  mergeKeyStorePayloads,
234
248
  };
@@ -11,6 +11,9 @@ const {
11
11
  DEFAULT_BASE_BRANCH,
12
12
  evaluateOperationalIntegrity,
13
13
  } = require('./operational-integrity');
14
+ const {
15
+ evaluateWorkflowSentinel,
16
+ } = require('./workflow-sentinel');
14
17
 
15
18
  /**
16
19
  * Computes the SHA-256 hash of an executable binary to prevent path-based bypasses.
@@ -764,6 +767,16 @@ function buildReasoning(gate, toolName, toolInput, extras = {}) {
764
767
  steps.push(`Memory guard matched (${extras.memoryGuard.source}): ${extras.memoryGuard.reason}`);
765
768
  }
766
769
 
770
+ if (extras.workflowSentinel) {
771
+ steps.push(`Workflow sentinel risk: ${extras.workflowSentinel.band} (${extras.workflowSentinel.riskScore})`);
772
+ if (extras.workflowSentinel.blastRadius && extras.workflowSentinel.blastRadius.summary) {
773
+ steps.push(`Workflow sentinel blast radius: ${extras.workflowSentinel.blastRadius.summary}`);
774
+ }
775
+ for (const remediation of (extras.workflowSentinel.remediations || []).slice(0, 3)) {
776
+ steps.push(`Workflow sentinel remediation: ${remediation.title} — ${remediation.action}`);
777
+ }
778
+ }
779
+
767
780
  // 5. Unless condition status
768
781
  if (gate.unless) {
769
782
  steps.push(`Bypassable via satisfy_gate("${gate.unless}") — not currently satisfied`);
@@ -973,6 +986,39 @@ function evaluateMemoryGuard(toolName, toolInput = {}) {
973
986
  };
974
987
  }
975
988
 
989
+ function buildSentinelGateResult(report) {
990
+ return {
991
+ decision: report.decision,
992
+ gate: 'workflow-sentinel',
993
+ message: `${report.summary} ${report.blastRadius.summary}`,
994
+ severity: report.decision === 'deny' ? 'critical' : 'high',
995
+ reasoning: Array.isArray(report.reasoning) ? report.reasoning.slice() : [],
996
+ sentinel: report,
997
+ };
998
+ }
999
+
1000
+ function enrichResultWithSentinel(result, report) {
1001
+ if (!result || !report || report.decision === 'allow') {
1002
+ return result;
1003
+ }
1004
+
1005
+ const next = {
1006
+ ...result,
1007
+ reasoning: Array.isArray(result.reasoning) ? result.reasoning.slice() : [],
1008
+ sentinel: report,
1009
+ };
1010
+
1011
+ if (report.blastRadius && report.blastRadius.summary) {
1012
+ next.message = `${result.message} Workflow sentinel: ${report.blastRadius.summary}`;
1013
+ }
1014
+
1015
+ next.reasoning = next.reasoning.concat(
1016
+ Array.isArray(report.reasoning) ? report.reasoning : []
1017
+ );
1018
+
1019
+ return next;
1020
+ }
1021
+
976
1022
  async function checkMetricCondition(metricCondition) {
977
1023
  if (!metricCondition) return true;
978
1024
  const { getBusinessMetrics } = require('./semantic-layer');
@@ -1058,20 +1104,40 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
1058
1104
  }
1059
1105
  }
1060
1106
 
1107
+ const sentinelReport = evaluateWorkflowSentinel(toolName, toolInput, {
1108
+ governanceState: loadGovernanceState(),
1109
+ });
1061
1110
  const memoryGuard = evaluateMemoryGuard(toolName, toolInput);
1062
1111
  if (memoryGuard) {
1063
- recordStat(memoryGuard.gate, 'block');
1112
+ const enrichedMemoryGuard = enrichResultWithSentinel(memoryGuard, sentinelReport);
1113
+ recordStat(enrichedMemoryGuard.gate, 'block');
1064
1114
  const auditRecord = recordAuditEvent({
1065
1115
  toolName,
1066
1116
  toolInput,
1067
1117
  decision: 'deny',
1068
- gateId: memoryGuard.gate,
1069
- message: memoryGuard.message,
1070
- severity: memoryGuard.severity,
1118
+ gateId: enrichedMemoryGuard.gate,
1119
+ message: enrichedMemoryGuard.message,
1120
+ severity: enrichedMemoryGuard.severity,
1071
1121
  source: 'gates-engine',
1072
1122
  });
1073
1123
  auditToFeedback(auditRecord);
1074
- return memoryGuard;
1124
+ return enrichedMemoryGuard;
1125
+ }
1126
+
1127
+ if (sentinelReport && sentinelReport.decision !== 'allow') {
1128
+ const sentinelResult = buildSentinelGateResult(sentinelReport);
1129
+ recordStat(sentinelResult.gate, sentinelResult.decision === 'deny' ? 'block' : 'warn');
1130
+ const auditRecord = recordAuditEvent({
1131
+ toolName,
1132
+ toolInput,
1133
+ decision: sentinelResult.decision,
1134
+ gateId: sentinelResult.gate,
1135
+ message: sentinelResult.message,
1136
+ severity: sentinelResult.severity,
1137
+ source: 'workflow-sentinel',
1138
+ });
1139
+ auditToFeedback(auditRecord);
1140
+ return sentinelResult;
1075
1141
  }
1076
1142
 
1077
1143
  // Audit trail: record allow (no gate matched)
@@ -1124,20 +1190,40 @@ function evaluateGates(toolName, toolInput, configPath) {
1124
1190
  }
1125
1191
  }
1126
1192
 
1193
+ const sentinelReport = evaluateWorkflowSentinel(toolName, toolInput, {
1194
+ governanceState: loadGovernanceState(),
1195
+ });
1127
1196
  const memoryGuard = evaluateMemoryGuard(toolName, toolInput);
1128
1197
  if (memoryGuard) {
1129
- recordStat(memoryGuard.gate, 'block');
1198
+ const enrichedMemoryGuard = enrichResultWithSentinel(memoryGuard, sentinelReport);
1199
+ recordStat(enrichedMemoryGuard.gate, 'block');
1130
1200
  const auditRecord = recordAuditEvent({
1131
1201
  toolName,
1132
1202
  toolInput,
1133
1203
  decision: 'deny',
1134
- gateId: memoryGuard.gate,
1135
- message: memoryGuard.message,
1136
- severity: memoryGuard.severity,
1204
+ gateId: enrichedMemoryGuard.gate,
1205
+ message: enrichedMemoryGuard.message,
1206
+ severity: enrichedMemoryGuard.severity,
1137
1207
  source: 'gates-engine',
1138
1208
  });
1139
1209
  auditToFeedback(auditRecord);
1140
- return memoryGuard;
1210
+ return enrichedMemoryGuard;
1211
+ }
1212
+
1213
+ if (sentinelReport && sentinelReport.decision !== 'allow') {
1214
+ const sentinelResult = buildSentinelGateResult(sentinelReport);
1215
+ recordStat(sentinelResult.gate, sentinelResult.decision === 'deny' ? 'block' : 'warn');
1216
+ const auditRecord = recordAuditEvent({
1217
+ toolName,
1218
+ toolInput,
1219
+ decision: sentinelResult.decision,
1220
+ gateId: sentinelResult.gate,
1221
+ message: sentinelResult.message,
1222
+ severity: sentinelResult.severity,
1223
+ source: 'workflow-sentinel',
1224
+ });
1225
+ auditToFeedback(auditRecord);
1226
+ return sentinelResult;
1141
1227
  }
1142
1228
 
1143
1229
  // Audit trail: record allow
@@ -10,7 +10,7 @@ PROMPT_GUARD="$SCRIPT_DIR/prompt-guard.js"
10
10
  ACTIVE_CWD="${CLAUDE_PROJECT_DIR:-${PWD:-$(pwd)}}"
11
11
  FEEDBACK_DIR="$(node -e "const path = require('path'); const { resolveFeedbackDir } = require(path.join(process.argv[1], 'feedback-paths.js')); process.stdout.write(resolveFeedbackDir({ cwd: process.argv[2] || process.cwd(), feedbackDir: process.env.THUMBGATE_FEEDBACK_DIR || undefined }));" "$SCRIPT_DIR" "$ACTIVE_CWD" 2>/dev/null)"
12
12
  if [ -z "$FEEDBACK_DIR" ]; then
13
- FEEDBACK_DIR="${THUMBGATE_FEEDBACK_DIR:-$ACTIVE_CWD/.rlhf}"
13
+ FEEDBACK_DIR="${THUMBGATE_FEEDBACK_DIR:-$ACTIVE_CWD/.thumbgate}"
14
14
  fi
15
15
  FEEDBACK_LOG="$FEEDBACK_DIR/feedback-log.jsonl"
16
16
  MEMORY_LOG="$FEEDBACK_DIR/memory-log.jsonl"