thumbgate 1.3.0 → 1.4.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 (146) hide show
  1. package/.claude-plugin/marketplace.json +32 -13
  2. package/.claude-plugin/plugin.json +15 -2
  3. package/.well-known/llms.txt +60 -0
  4. package/.well-known/mcp/server-card.json +1 -1
  5. package/README.md +109 -20
  6. package/adapters/README.md +1 -1
  7. package/adapters/chatgpt/openapi.yaml +168 -0
  8. package/adapters/claude/.mcp.json +2 -2
  9. package/adapters/codex/config.toml +2 -2
  10. package/adapters/mcp/server-stdio.js +84 -1
  11. package/adapters/opencode/opencode.json +1 -1
  12. package/bin/cli.js +200 -13
  13. package/bin/postinstall.js +8 -2
  14. package/config/budget.json +18 -0
  15. package/config/gates/code-edit.json +61 -0
  16. package/config/gates/db-write.json +61 -0
  17. package/config/gates/default.json +154 -3
  18. package/config/gates/deploy.json +61 -0
  19. package/config/github-about.json +2 -1
  20. package/config/merge-quality-checks.json +23 -0
  21. package/openapi/openapi.yaml +168 -0
  22. package/package.json +42 -10
  23. package/plugins/claude-codex-bridge/.claude-plugin/plugin.json +1 -1
  24. package/plugins/claude-codex-bridge/.mcp.json +1 -1
  25. package/plugins/claude-codex-bridge/scripts/codex-bridge.js +1 -3
  26. package/plugins/codex-profile/.codex-plugin/plugin.json +1 -1
  27. package/plugins/codex-profile/.mcp.json +1 -1
  28. package/plugins/codex-profile/INSTALL.md +27 -4
  29. package/plugins/codex-profile/README.md +33 -9
  30. package/plugins/cursor-marketplace/.cursor-plugin/plugin.json +1 -1
  31. package/plugins/opencode-profile/INSTALL.md +1 -1
  32. package/public/blog.html +73 -0
  33. package/public/compare/mem0.html +189 -0
  34. package/public/compare/speclock.html +180 -0
  35. package/public/compare.html +10 -2
  36. package/public/guide.html +2 -2
  37. package/public/guides/claude-code-prevent-repeated-mistakes.html +161 -0
  38. package/public/guides/codex-cli-guardrails.html +158 -0
  39. package/public/guides/cursor-prevent-repeated-mistakes.html +161 -0
  40. package/public/guides/pre-action-gates.html +162 -0
  41. package/public/guides/stop-repeated-ai-agent-mistakes.html +159 -0
  42. package/public/index.html +136 -50
  43. package/public/lessons.html +33 -24
  44. package/public/llm-context.md +140 -0
  45. package/public/pro.html +24 -22
  46. package/scripts/__pycache__/train_from_feedback.cpython-312.pyc +0 -0
  47. package/scripts/access-anomaly-detector.js +1 -1
  48. package/scripts/adk-consolidator.js +1 -5
  49. package/scripts/agent-security-hardening.js +4 -6
  50. package/scripts/agentic-data-pipeline.js +1 -3
  51. package/scripts/async-job-runner.js +1 -5
  52. package/scripts/audit-trail.js +1 -5
  53. package/scripts/background-agent-governance.js +2 -10
  54. package/scripts/billing.js +2 -16
  55. package/scripts/budget-enforcer.js +173 -0
  56. package/scripts/build-codex-plugin.js +152 -0
  57. package/scripts/check-congruence.js +132 -14
  58. package/scripts/commercial-offer.js +5 -7
  59. package/scripts/content-engine/linkedin-content-generator.js +154 -0
  60. package/scripts/content-engine/output/linkedin-memento-validation.md +17 -0
  61. package/scripts/content-engine/output/linkedin-posts-2026-04-09.md +175 -0
  62. package/scripts/content-engine/reddit-thread-finder.js +154 -0
  63. package/scripts/context-engine.js +21 -6
  64. package/scripts/contextfs.js +1 -21
  65. package/scripts/dashboard.js +20 -0
  66. package/scripts/decision-journal.js +341 -0
  67. package/scripts/delegation-runtime.js +1 -5
  68. package/scripts/distribution-surfaces.js +26 -0
  69. package/scripts/document-intake.js +927 -0
  70. package/scripts/ephemeral-agent-store.js +1 -8
  71. package/scripts/evolution-state.js +1 -5
  72. package/scripts/experiment-tracker.js +1 -5
  73. package/scripts/export-databricks-bundle.js +1 -5
  74. package/scripts/export-hf-dataset.js +1 -5
  75. package/scripts/export-training.js +1 -5
  76. package/scripts/feedback-attribution.js +1 -16
  77. package/scripts/feedback-history-distiller.js +1 -16
  78. package/scripts/feedback-loop.js +1 -5
  79. package/scripts/feedback-root-consolidator.js +2 -21
  80. package/scripts/feedback-session.js +49 -0
  81. package/scripts/feedback-to-rules.js +188 -28
  82. package/scripts/filesystem-search.js +1 -9
  83. package/scripts/fs-utils.js +104 -0
  84. package/scripts/gates-engine.js +149 -4
  85. package/scripts/github-about.js +32 -8
  86. package/scripts/gtm-revenue-loop.js +1 -5
  87. package/scripts/harness-selector.js +148 -0
  88. package/scripts/hosted-job-launcher.js +1 -5
  89. package/scripts/hybrid-feedback-context.js +7 -33
  90. package/scripts/intervention-policy.js +58 -1
  91. package/scripts/lesson-db.js +3 -18
  92. package/scripts/lesson-inference.js +194 -16
  93. package/scripts/lesson-retrieval.js +60 -24
  94. package/scripts/llm-client.js +59 -0
  95. package/scripts/managed-lesson-agent.js +183 -0
  96. package/scripts/marketing-experiment.js +8 -22
  97. package/scripts/meta-agent-loop.js +624 -0
  98. package/scripts/metered-billing.js +1 -1
  99. package/scripts/money-watcher.js +1 -4
  100. package/scripts/obsidian-export.js +1 -5
  101. package/scripts/operational-integrity.js +15 -3
  102. package/scripts/org-dashboard.js +6 -1
  103. package/scripts/per-step-scoring.js +2 -4
  104. package/scripts/pr-manager.js +201 -19
  105. package/scripts/pro-features.js +3 -2
  106. package/scripts/prompt-dlp.js +3 -3
  107. package/scripts/prove-adapters.js +1 -5
  108. package/scripts/prove-attribution.js +1 -5
  109. package/scripts/prove-automation.js +1 -3
  110. package/scripts/prove-cloudflare-sandbox.js +1 -3
  111. package/scripts/prove-data-pipeline.js +1 -3
  112. package/scripts/prove-intelligence.js +1 -3
  113. package/scripts/prove-lancedb.js +1 -5
  114. package/scripts/prove-local-intelligence.js +1 -3
  115. package/scripts/prove-packaged-runtime.js +75 -9
  116. package/scripts/prove-predictive-insights.js +1 -3
  117. package/scripts/prove-training-export.js +1 -3
  118. package/scripts/prove-workflow-contract.js +1 -5
  119. package/scripts/rate-limiter.js +3 -1
  120. package/scripts/reddit-dm-outreach.js +14 -4
  121. package/scripts/schedule-manager.js +3 -5
  122. package/scripts/security-scanner.js +448 -0
  123. package/scripts/self-distill-agent.js +579 -0
  124. package/scripts/semantic-dedup.js +115 -0
  125. package/scripts/skill-exporter.js +1 -3
  126. package/scripts/skill-generator.js +1 -5
  127. package/scripts/social-analytics/engagement-audit.js +1 -18
  128. package/scripts/social-analytics/pollers/linkedin.js +26 -16
  129. package/scripts/social-analytics/publishers/linkedin.js +1 -1
  130. package/scripts/social-analytics/publishers/zernio.js +51 -0
  131. package/scripts/social-pipeline.js +1 -3
  132. package/scripts/social-post-hourly.js +47 -4
  133. package/scripts/statusline-links.js +6 -5
  134. package/scripts/statusline.sh +29 -153
  135. package/scripts/sync-branch-protection.js +340 -0
  136. package/scripts/tessl-export.js +1 -3
  137. package/scripts/thumbgate-search.js +32 -1
  138. package/scripts/tool-kpi-tracker.js +1 -1
  139. package/scripts/tool-registry.js +106 -2
  140. package/scripts/vector-store.js +1 -5
  141. package/scripts/weekly-auto-post.js +1 -1
  142. package/scripts/workflow-sentinel.js +91 -0
  143. package/skills/thumbgate/SKILL.md +1 -1
  144. package/src/api/server.js +273 -4
  145. package/scripts/social-analytics/db/social-analytics.db-shm +0 -0
  146. /package/scripts/social-analytics/db/{social-analytics.db-wal → analytics.sqlite} +0 -0
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Shared filesystem utilities.
6
+ *
7
+ * Consolidates ensureDir() and readJsonl() which were duplicated
8
+ * across 43 and 19 files respectively.
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+
14
+ /**
15
+ * Recursively create a directory if it does not exist.
16
+ * @param {string} dirPath
17
+ */
18
+ function ensureDir(dirPath) {
19
+ if (!fs.existsSync(dirPath)) {
20
+ fs.mkdirSync(dirPath, { recursive: true });
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Recursively create the parent directory for a file path.
26
+ * @param {string} filePath
27
+ */
28
+ function ensureParentDir(filePath) {
29
+ ensureDir(path.dirname(filePath));
30
+ }
31
+
32
+ /**
33
+ * Read a JSONL (JSON Lines) file into an array of parsed objects.
34
+ * Silently skips malformed lines and returns [] if file is missing.
35
+ *
36
+ * @param {string} filePath
37
+ * @param {object} [options]
38
+ * @param {number} [options.maxLines] - Read at most N lines (from the end if reverse=true)
39
+ * @param {boolean} [options.reverse] - Read lines in reverse order (most recent first)
40
+ * @param {boolean} [options.tail] - Read from the end while preserving chronological order
41
+ * @returns {object[]}
42
+ */
43
+ function readJsonl(filePath, options = {}) {
44
+ if (!filePath || !fs.existsSync(filePath)) return [];
45
+ const raw = fs.readFileSync(filePath, 'utf-8').trim();
46
+ if (!raw) return [];
47
+
48
+ const normalizedOptions = typeof options === 'number'
49
+ ? { maxLines: options, tail: true }
50
+ : (options || {});
51
+ let lines = raw.split('\n');
52
+
53
+ if (normalizedOptions.tail && normalizedOptions.maxLines > 0) {
54
+ lines = lines.slice(-normalizedOptions.maxLines);
55
+ }
56
+
57
+ if (normalizedOptions.reverse) {
58
+ lines = lines.reverse();
59
+ }
60
+
61
+ if (!normalizedOptions.tail && normalizedOptions.maxLines && normalizedOptions.maxLines > 0) {
62
+ lines = lines.slice(0, normalizedOptions.maxLines);
63
+ }
64
+
65
+ return lines
66
+ .map((line) => {
67
+ try {
68
+ return JSON.parse(line);
69
+ } catch {
70
+ return null;
71
+ }
72
+ })
73
+ .filter(Boolean);
74
+ }
75
+
76
+ /**
77
+ * Append a JSON object as a line to a JSONL file.
78
+ * Creates parent directories if they do not exist.
79
+ *
80
+ * @param {string} filePath
81
+ * @param {object} payload
82
+ */
83
+ function appendJsonl(filePath, payload) {
84
+ ensureParentDir(filePath);
85
+ fs.appendFileSync(filePath, JSON.stringify(payload) + '\n');
86
+ }
87
+
88
+ /**
89
+ * Write a JSON object to a file with pretty-printing.
90
+ * Creates parent directories if they do not exist.
91
+ *
92
+ * @param {string} filePath
93
+ * @param {object} payload
94
+ */
95
+ function writeJson(filePath, payload) {
96
+ ensureParentDir(filePath);
97
+ fs.writeFileSync(filePath, JSON.stringify(payload, null, 2) + '\n');
98
+ }
99
+
100
+ function readJsonlTail(filePath, limit) {
101
+ return readJsonl(filePath, { maxLines: limit, tail: true });
102
+ }
103
+
104
+ module.exports = { ensureDir, ensureParentDir, readJsonl, readJsonlTail, appendJsonl, writeJson };
@@ -14,6 +14,10 @@ const {
14
14
  const {
15
15
  evaluateWorkflowSentinel,
16
16
  } = require('./workflow-sentinel');
17
+ const {
18
+ recordDecisionEvaluation,
19
+ recordDecisionOutcome,
20
+ } = require('./decision-journal');
17
21
 
18
22
  /**
19
23
  * Computes the SHA-256 hash of an executable binary to prevent path-based bypasses.
@@ -47,6 +51,9 @@ const {
47
51
  buildSafeSummary,
48
52
  redactText,
49
53
  } = require('./secret-scanner');
54
+ const {
55
+ evaluateSecurityScan,
56
+ } = require('./security-scanner');
50
57
  const { getAutoGatesPath } = require('./auto-promote-gates');
51
58
  const { recordAuditEvent, auditToFeedback } = require('./audit-trail');
52
59
 
@@ -81,7 +88,7 @@ const HIGH_RISK_BASH_PATTERN = /\b(?:git\s+(?:add|commit|push)|gh\s+pr\s+(?:crea
81
88
  // Config loading
82
89
  // ---------------------------------------------------------------------------
83
90
 
84
- function loadGatesConfig(configPath) {
91
+ function loadGatesConfig(configPath, harnessPath) {
85
92
  const primaryPath = configPath || process.env.THUMBGATE_GATES_CONFIG || DEFAULT_CONFIG_PATH;
86
93
 
87
94
  if (!fs.existsSync(primaryPath)) {
@@ -120,6 +127,15 @@ function loadGatesConfig(configPath) {
120
127
  mergedConfig.gates.push(...limitedAutoGates);
121
128
  }
122
129
 
130
+ // Load workflow-specific harness gates (always additive, never replaces default).
131
+ // Resolved by harness-selector based on tool name + command context.
132
+ const resolvedHarness = harnessPath || process.env.THUMBGATE_HARNESS_CONFIG;
133
+ if (resolvedHarness && fs.existsSync(resolvedHarness)) {
134
+ const harnessGates = (loadOne(resolvedHarness, false) || [])
135
+ .map(g => ({ ...g, layer: g.layer || 'Execution', source: g.source || 'harness' }));
136
+ mergedConfig.gates.push(...harnessGates);
137
+ }
138
+
123
139
  return mergedConfig;
124
140
  }
125
141
 
@@ -407,11 +423,15 @@ function recordStat(gateId, action, gate) {
407
423
  const stats = loadStats();
408
424
  if (action === 'block') stats.blocked = (stats.blocked || 0) + 1;
409
425
  else if (action === 'warn') stats.warned = (stats.warned || 0) + 1;
426
+ else if (action === 'approve') stats.pendingApproval = (stats.pendingApproval || 0) + 1;
427
+ else if (action === 'log') stats.logged = (stats.logged || 0) + 1;
410
428
  else stats.passed = (stats.passed || 0) + 1;
411
429
  if (!stats.byGate) stats.byGate = {};
412
- if (!stats.byGate[gateId]) stats.byGate[gateId] = { blocked: 0, warned: 0 };
430
+ if (!stats.byGate[gateId]) stats.byGate[gateId] = { blocked: 0, warned: 0, pendingApproval: 0, logged: 0 };
413
431
  if (action === 'block') stats.byGate[gateId].blocked += 1;
414
432
  else if (action === 'warn') stats.byGate[gateId].warned += 1;
433
+ else if (action === 'approve') stats.byGate[gateId].pendingApproval = (stats.byGate[gateId].pendingApproval || 0) + 1;
434
+ else if (action === 'log') stats.byGate[gateId].logged = (stats.byGate[gateId].logged || 0) + 1;
415
435
  saveStats(stats);
416
436
  // Track lesson freshness when an auto-promoted gate fires
417
437
  if (gate && gate.sourceLessonId) {
@@ -997,6 +1017,47 @@ function buildSentinelGateResult(report) {
997
1017
  };
998
1018
  }
999
1019
 
1020
+ function recordSentinelDecision(report, toolName, toolInput) {
1021
+ if (!report) return null;
1022
+ const entry = recordDecisionEvaluation(report, {
1023
+ source: 'gates-engine',
1024
+ toolName,
1025
+ toolInput,
1026
+ changedFiles: report && report.blastRadius && Array.isArray(report.blastRadius.affectedFiles)
1027
+ ? report.blastRadius.affectedFiles
1028
+ : [],
1029
+ });
1030
+ report.actionId = entry.actionId;
1031
+ if (report.decisionControl && !report.decisionControl.actionId) {
1032
+ report.decisionControl.actionId = entry.actionId;
1033
+ }
1034
+ return entry;
1035
+ }
1036
+
1037
+ function recordMemoryGuardDecision(sentinelDecision, enrichedMemoryGuard) {
1038
+ if (!sentinelDecision) return;
1039
+ recordDecisionOutcome({
1040
+ actionId: sentinelDecision.actionId,
1041
+ outcome: 'blocked',
1042
+ actualDecision: 'deny',
1043
+ actor: 'system',
1044
+ source: 'gates-engine',
1045
+ notes: enrichedMemoryGuard.message,
1046
+ });
1047
+ }
1048
+
1049
+ function recordSentinelBlockDecision(sentinelDecision, sentinelResult) {
1050
+ if (!sentinelDecision) return;
1051
+ recordDecisionOutcome({
1052
+ actionId: sentinelDecision.actionId,
1053
+ outcome: sentinelResult.decision === 'deny' ? 'blocked' : 'warned',
1054
+ actualDecision: sentinelResult.decision,
1055
+ actor: 'system',
1056
+ source: 'workflow-sentinel',
1057
+ notes: sentinelResult.message,
1058
+ });
1059
+ }
1060
+
1000
1061
  function enrichResultWithSentinel(result, report) {
1001
1062
  if (!result || !report || report.decision === 'allow') {
1002
1063
  return result;
@@ -1036,7 +1097,12 @@ async function checkMetricCondition(metricCondition) {
1036
1097
  async function evaluateGatesAsync(toolName, toolInput, configPath) {
1037
1098
  let config;
1038
1099
  try {
1039
- config = loadGatesConfig(configPath);
1100
+ let harnessPath;
1101
+ try {
1102
+ const { selectHarness } = require('./harness-selector');
1103
+ harnessPath = selectHarness(toolName, toolInput);
1104
+ } catch { /* harness-selector is optional */ }
1105
+ config = loadGatesConfig(configPath, harnessPath);
1040
1106
  } catch {
1041
1107
  return null;
1042
1108
  }
@@ -1095,6 +1161,23 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
1095
1161
  return result;
1096
1162
  }
1097
1163
 
1164
+ if (gate.action === 'approve') {
1165
+ recordStat(gate.id, 'approve', gate);
1166
+ const result = { decision: 'approve', gate: gate.id, message, severity: gate.severity, reasoning, requiresApproval: true };
1167
+ const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'approve', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
1168
+ auditToFeedback(auditRecord);
1169
+ return result;
1170
+ }
1171
+
1172
+ if (gate.action === 'log') {
1173
+ recordStat(gate.id, 'log', gate);
1174
+ const result = { decision: 'log', gate: gate.id, message, severity: gate.severity, reasoning, logged: true };
1175
+ const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'log', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
1176
+ auditToFeedback(auditRecord);
1177
+ // 'log' action allows the tool call to proceed — do not return early, continue to next gate
1178
+ continue;
1179
+ }
1180
+
1098
1181
  if (gate.action === 'warn') {
1099
1182
  recordStat(gate.id, 'warn', gate);
1100
1183
  const result = { decision: 'warn', gate: gate.id, message, severity: gate.severity, reasoning };
@@ -1107,10 +1190,12 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
1107
1190
  const sentinelReport = evaluateWorkflowSentinel(toolName, toolInput, {
1108
1191
  governanceState: loadGovernanceState(),
1109
1192
  });
1193
+ const sentinelDecision = recordSentinelDecision(sentinelReport, toolName, toolInput);
1110
1194
  const memoryGuard = evaluateMemoryGuard(toolName, toolInput);
1111
1195
  if (memoryGuard) {
1112
1196
  const enrichedMemoryGuard = enrichResultWithSentinel(memoryGuard, sentinelReport);
1113
1197
  recordStat(enrichedMemoryGuard.gate, 'block');
1198
+ recordMemoryGuardDecision(sentinelDecision, enrichedMemoryGuard);
1114
1199
  const auditRecord = recordAuditEvent({
1115
1200
  toolName,
1116
1201
  toolInput,
@@ -1127,6 +1212,7 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
1127
1212
  if (sentinelReport && sentinelReport.decision !== 'allow') {
1128
1213
  const sentinelResult = buildSentinelGateResult(sentinelReport);
1129
1214
  recordStat(sentinelResult.gate, sentinelResult.decision === 'deny' ? 'block' : 'warn');
1215
+ recordSentinelBlockDecision(sentinelDecision, sentinelResult);
1130
1216
  const auditRecord = recordAuditEvent({
1131
1217
  toolName,
1132
1218
  toolInput,
@@ -1148,7 +1234,12 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
1148
1234
  function evaluateGates(toolName, toolInput, configPath) {
1149
1235
  let config;
1150
1236
  try {
1151
- config = loadGatesConfig(configPath);
1237
+ let harnessPath;
1238
+ try {
1239
+ const { selectHarness } = require('./harness-selector');
1240
+ harnessPath = selectHarness(toolName, toolInput);
1241
+ } catch { /* harness-selector is optional */ }
1242
+ config = loadGatesConfig(configPath, harnessPath);
1152
1243
  } catch {
1153
1244
  // If config can't be loaded, pass through
1154
1245
  return null;
@@ -1181,6 +1272,22 @@ function evaluateGates(toolName, toolInput, configPath) {
1181
1272
  return result;
1182
1273
  }
1183
1274
 
1275
+ if (gate.action === 'approve') {
1276
+ recordStat(gate.id, 'approve', gate);
1277
+ const result = { decision: 'approve', gate: gate.id, message, severity: gate.severity, reasoning, requiresApproval: true };
1278
+ const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'approve', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
1279
+ auditToFeedback(auditRecord);
1280
+ return result;
1281
+ }
1282
+
1283
+ if (gate.action === 'log') {
1284
+ recordStat(gate.id, 'log', gate);
1285
+ const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'log', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
1286
+ auditToFeedback(auditRecord);
1287
+ // 'log' action allows the tool call to proceed — continue to next gate
1288
+ continue;
1289
+ }
1290
+
1184
1291
  if (gate.action === 'warn') {
1185
1292
  recordStat(gate.id, 'warn', gate);
1186
1293
  const result = { decision: 'warn', gate: gate.id, message, severity: gate.severity, reasoning };
@@ -1193,10 +1300,12 @@ function evaluateGates(toolName, toolInput, configPath) {
1193
1300
  const sentinelReport = evaluateWorkflowSentinel(toolName, toolInput, {
1194
1301
  governanceState: loadGovernanceState(),
1195
1302
  });
1303
+ const sentinelDecision = recordSentinelDecision(sentinelReport, toolName, toolInput);
1196
1304
  const memoryGuard = evaluateMemoryGuard(toolName, toolInput);
1197
1305
  if (memoryGuard) {
1198
1306
  const enrichedMemoryGuard = enrichResultWithSentinel(memoryGuard, sentinelReport);
1199
1307
  recordStat(enrichedMemoryGuard.gate, 'block');
1308
+ recordMemoryGuardDecision(sentinelDecision, enrichedMemoryGuard);
1200
1309
  const auditRecord = recordAuditEvent({
1201
1310
  toolName,
1202
1311
  toolInput,
@@ -1213,6 +1322,7 @@ function evaluateGates(toolName, toolInput, configPath) {
1213
1322
  if (sentinelReport && sentinelReport.decision !== 'allow') {
1214
1323
  const sentinelResult = buildSentinelGateResult(sentinelReport);
1215
1324
  recordStat(sentinelResult.gate, sentinelResult.decision === 'deny' ? 'block' : 'warn');
1325
+ recordSentinelBlockDecision(sentinelDecision, sentinelResult);
1216
1326
  const auditRecord = recordAuditEvent({
1217
1327
  toolName,
1218
1328
  toolInput,
@@ -1374,9 +1484,26 @@ async function runAsync(input) {
1374
1484
  return formatOutput(secretGuard);
1375
1485
  }
1376
1486
 
1487
+ // Security vulnerability scan (Tier 1: pattern match, Tier 2: supply chain)
1488
+ const securityScan = evaluateSecurityScan(input);
1489
+ if (securityScan && securityScan.decision === 'deny') {
1490
+ return formatOutput(securityScan);
1491
+ }
1492
+
1377
1493
  const toolName = input.tool_name || '';
1378
1494
  const toolInput = input.tool_input || {};
1379
1495
  const result = await evaluateGatesAsync(toolName, toolInput);
1496
+
1497
+ // Attach security warnings to allow/warn results
1498
+ if (securityScan && securityScan.decision === 'warn') {
1499
+ if (result) {
1500
+ result.securityWarnings = securityScan.securityScan.findings;
1501
+ result.reasoning = (result.reasoning || []).concat(securityScan.reasoning);
1502
+ } else {
1503
+ return formatOutput(securityScan);
1504
+ }
1505
+ }
1506
+
1380
1507
  return formatOutput(result);
1381
1508
  }
1382
1509
 
@@ -1386,9 +1513,26 @@ function run(input) {
1386
1513
  return formatOutput(secretGuard);
1387
1514
  }
1388
1515
 
1516
+ // Security vulnerability scan (Tier 1: pattern match, Tier 2: supply chain)
1517
+ const securityScan = evaluateSecurityScan(input);
1518
+ if (securityScan && securityScan.decision === 'deny') {
1519
+ return formatOutput(securityScan);
1520
+ }
1521
+
1389
1522
  const toolName = input.tool_name || '';
1390
1523
  const toolInput = input.tool_input || {};
1391
1524
  const result = evaluateGates(toolName, toolInput);
1525
+
1526
+ // Attach security warnings to allow/warn results
1527
+ if (securityScan && securityScan.decision === 'warn') {
1528
+ if (result) {
1529
+ result.securityWarnings = securityScan.securityScan.findings;
1530
+ result.reasoning = (result.reasoning || []).concat(securityScan.reasoning);
1531
+ } else {
1532
+ return formatOutput(securityScan);
1533
+ }
1534
+ }
1535
+
1392
1536
  return formatOutput(result);
1393
1537
  }
1394
1538
 
@@ -1580,6 +1724,7 @@ module.exports = {
1580
1724
  saveStats,
1581
1725
  recordStat,
1582
1726
  evaluateSecretGuard,
1727
+ evaluateSecurityScan,
1583
1728
  buildSecretGuardResult,
1584
1729
  buildReasoning,
1585
1730
  matchesGate,
@@ -14,6 +14,9 @@ const LEGACY_REPOSITORY_URL = 'https://github.com/IgorGanapolsky/thumbgate';
14
14
  const GITHUB_API_BASE_URL = 'https://api.github.com';
15
15
  const DEFAULT_VERIFY_ATTEMPTS = 5;
16
16
  const DEFAULT_VERIFY_DELAY_MS = 2000;
17
+ const MAX_GITHUB_DESCRIPTION_LENGTH = 160;
18
+ const VERIFY_ATTEMPTS_ENV = 'THUMBGATE_GITHUB_ABOUT_VERIFY_ATTEMPTS';
19
+ const VERIFY_DELAY_MS_ENV = 'THUMBGATE_GITHUB_ABOUT_VERIFY_DELAY_MS';
17
20
 
18
21
  function readText(root, relativePath) {
19
22
  return fs.readFileSync(path.join(root, relativePath), 'utf8');
@@ -63,11 +66,14 @@ function hasRepositoryUrl(text, targetUrl) {
63
66
 
64
67
  function loadGitHubAboutConfig(root = ROOT) {
65
68
  const about = readJson(root, CONFIG_RELATIVE_PATH);
69
+ const metaDescription = normalizeText(about.metaDescription || about.description);
70
+ const githubDescription = normalizeText(about.githubDescription || about.description);
66
71
  return {
67
72
  repo: normalizeText(about.repo),
68
73
  repositoryUrl: normalizeText(about.repositoryUrl),
69
74
  homepageUrl: normalizeText(about.homepageUrl),
70
- description: normalizeText(about.description),
75
+ githubDescription,
76
+ metaDescription,
71
77
  topics: normalizeTopics(about.topics),
72
78
  };
73
79
  }
@@ -101,7 +107,7 @@ function compareGitHubAbout(expected, actual, label = 'Live GitHub About') {
101
107
  const actualHomepage = normalizeText(actual.homepageUrl || actual.homepage);
102
108
  const actualTopics = normalizeTopics(actual.topics);
103
109
 
104
- if (actualDescription !== expected.description) {
110
+ if (actualDescription !== expected.githubDescription) {
105
111
  errors.push(`${label} description mismatch`);
106
112
  }
107
113
  if (actualHomepage !== expected.homepageUrl) {
@@ -135,8 +141,12 @@ function collectLocalGitHubAboutErrors(root = ROOT) {
135
141
  }
136
142
 
137
143
  check(
138
- extractMetaDescription(landingHtml) === about.description,
139
- 'config/github-about.json description must match public/index.html meta description'
144
+ extractMetaDescription(landingHtml) === about.metaDescription,
145
+ 'config/github-about.json metaDescription must match public/index.html meta description'
146
+ );
147
+ check(
148
+ about.githubDescription.length <= MAX_GITHUB_DESCRIPTION_LENGTH,
149
+ `config/github-about.json githubDescription must be ${MAX_GITHUB_DESCRIPTION_LENGTH} characters or fewer for GitHub repo metadata`
140
150
  );
141
151
  check(
142
152
  packageJson.homepage === about.homepageUrl,
@@ -179,7 +189,11 @@ function collectLocalGitHubAboutErrors(root = ROOT) {
179
189
  'docs/MARKETING_COPY_CONGRUENCE.md must reference config/github-about.json as the source of truth'
180
190
  );
181
191
  check(
182
- marketingCopy.includes(about.description),
192
+ marketingCopy.includes(about.metaDescription),
193
+ 'docs/MARKETING_COPY_CONGRUENCE.md must include the canonical landing meta description'
194
+ );
195
+ check(
196
+ marketingCopy.includes(about.githubDescription),
183
197
  'docs/MARKETING_COPY_CONGRUENCE.md must include the canonical GitHub About description'
184
198
  );
185
199
  check(
@@ -303,6 +317,13 @@ function normalizePositiveInteger(value, fallback) {
303
317
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
304
318
  }
305
319
 
320
+ function resolveVerifySetting(optionValue, envName, fallback) {
321
+ if (optionValue !== undefined) {
322
+ return normalizePositiveInteger(optionValue, fallback);
323
+ }
324
+ return normalizePositiveInteger(process.env[envName], fallback);
325
+ }
326
+
306
327
  function sleep(delayMs) {
307
328
  return new Promise((resolve) => {
308
329
  setTimeout(resolve, delayMs);
@@ -314,8 +335,8 @@ async function verifyLiveGitHubAbout(options = {}) {
314
335
  const expected = options.expected || loadGitHubAboutConfig(root);
315
336
  const repo = normalizeText(options.repo) || expected.repo;
316
337
  const label = options.label || `Live GitHub About (${repo})`;
317
- const attempts = normalizePositiveInteger(options.attempts, DEFAULT_VERIFY_ATTEMPTS);
318
- const delayMs = normalizePositiveInteger(options.delayMs, DEFAULT_VERIFY_DELAY_MS);
338
+ const attempts = resolveVerifySetting(options.attempts, VERIFY_ATTEMPTS_ENV, DEFAULT_VERIFY_ATTEMPTS);
339
+ const delayMs = resolveVerifySetting(options.delayMs, VERIFY_DELAY_MS_ENV, DEFAULT_VERIFY_DELAY_MS);
319
340
  const fetcher = typeof options.fetcher === 'function' ? options.fetcher : fetchLiveGitHubAbout;
320
341
  const sleeper = typeof options.sleep === 'function' ? options.sleep : sleep;
321
342
  let actual = null;
@@ -362,7 +383,7 @@ async function updateLiveGitHubAbout(options = {}) {
362
383
  method: 'PATCH',
363
384
  headers: buildGitHubApiHeaders(token),
364
385
  body: JSON.stringify({
365
- description: about.description,
386
+ description: about.githubDescription,
366
387
  homepage: about.homepageUrl,
367
388
  }),
368
389
  });
@@ -390,6 +411,9 @@ module.exports = {
390
411
  DEFAULT_VERIFY_ATTEMPTS,
391
412
  DEFAULT_VERIFY_DELAY_MS,
392
413
  LEGACY_REPOSITORY_URL,
414
+ MAX_GITHUB_DESCRIPTION_LENGTH,
415
+ VERIFY_ATTEMPTS_ENV,
416
+ VERIFY_DELAY_MS_ENV,
393
417
  buildCanonicalRepoUrls,
394
418
  collectLocalGitHubAboutErrors,
395
419
  compareGitHubAbout,
@@ -6,6 +6,7 @@ const path = require('node:path');
6
6
  const { spawnSync } = require('node:child_process');
7
7
  const { resolveHostedBillingConfig } = require('./hosted-config');
8
8
  const { getOperationalBillingSummary } = require('./operational-summary');
9
+ const { ensureDir } = require('./fs-utils');
9
10
 
10
11
  const COMMERCIAL_TRUTH_LINK = 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/COMMERCIAL_TRUTH.md';
11
12
  const VERIFICATION_EVIDENCE_LINK = 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/VERIFICATION_EVIDENCE.md';
@@ -65,11 +66,6 @@ function clampTargetCount(value) {
65
66
  return Math.max(1, Math.min(parsed, 12));
66
67
  }
67
68
 
68
- function ensureDir(dirPath) {
69
- if (!dirPath) return;
70
- fs.mkdirSync(dirPath, { recursive: true });
71
- }
72
-
73
69
  function normalizeText(value) {
74
70
  if (value === undefined || value === null) return '';
75
71
  return String(value).trim();
@@ -0,0 +1,148 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Harness Selector — Context-Aware Gate Harness Loading
5
+ *
6
+ * Auto Agent concept: instead of one monolithic gate config, select a
7
+ * specialized harness based on the workflow type detected from the tool call.
8
+ *
9
+ * Detection priority (first match wins):
10
+ * 1. THUMBGATE_HARNESS env var — explicit override
11
+ * 2. Tool-name heuristic (Edit/Write/MultiEdit → code-edit)
12
+ * 3. Command-text heuristic (deploy keywords → deploy, SQL keywords → db-write)
13
+ * 4. null → load only default.json + auto-promoted gates
14
+ *
15
+ * Each harness is ADDITIVE — default.json gates always load first.
16
+ */
17
+
18
+ const path = require('path');
19
+
20
+ const HARNESS_DIR = path.join(__dirname, '..', 'config', 'gates');
21
+
22
+ const HARNESSES = Object.freeze({
23
+ deploy: path.join(HARNESS_DIR, 'deploy.json'),
24
+ 'code-edit': path.join(HARNESS_DIR, 'code-edit.json'),
25
+ 'db-write': path.join(HARNESS_DIR, 'db-write.json'),
26
+ });
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Detection patterns
30
+ // ---------------------------------------------------------------------------
31
+
32
+ const DEPLOY_PATTERNS = [
33
+ /\brailway\s+(deploy|up|run)\b/i,
34
+ /\bdocker\s+(push|build)\b/i,
35
+ /\bnpm\s+publish\b/i,
36
+ /\byarn\s+publish\b/i,
37
+ /\bpnpm\s+publish\b/i,
38
+ /\bgit\s+push\b/i,
39
+ /\bgh\s+pr\s+(create|merge)\b/i,
40
+ /\bchangeset\s+(publish|version)\b/i,
41
+ ];
42
+
43
+ const DB_WRITE_PATTERNS = [
44
+ /\b(DROP|TRUNCATE|DELETE|ALTER|INSERT|UPDATE)\s+(TABLE|FROM|INTO|COLUMN)\b/i,
45
+ /\b(sqlite3|better-sqlite3|knex|sequelize)\b.*\.(run|exec|query)\b/i,
46
+ /\brm\s+.*\.sqlite\b/i,
47
+ /\blancedb\b.*(?:create|delete|drop|truncate)/i,
48
+ /\.db\.exec\(|\.db\.prepare\(/i,
49
+ ];
50
+
51
+ const CODE_EDIT_TOOL_NAMES = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Public API
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /**
58
+ * Given a tool name and input, return the path to the best matching
59
+ * specialized harness config, or null if none applies.
60
+ *
61
+ * @param {string} toolName - e.g. "Bash", "Edit", "Write"
62
+ * @param {object|string} toolInput - raw tool input object or string
63
+ * @returns {string|null} absolute path to harness JSON, or null
64
+ */
65
+ function selectHarness(toolName, toolInput) {
66
+ // 1. Explicit override
67
+ if (process.env.THUMBGATE_HARNESS) {
68
+ const override = process.env.THUMBGATE_HARNESS;
69
+ if (HARNESSES[override]) return HARNESSES[override];
70
+ // Allow absolute path override
71
+ if (path.isAbsolute(override)) return override;
72
+ }
73
+
74
+ // 2. Edit/Write tools always get code-edit harness
75
+ if (CODE_EDIT_TOOL_NAMES.has(toolName)) {
76
+ return HARNESSES['code-edit'];
77
+ }
78
+
79
+ // 3. Inspect command text for Bash tool
80
+ const commandText = extractCommandText(toolInput);
81
+ if (commandText) {
82
+ if (DB_WRITE_PATTERNS.some((p) => p.test(commandText))) {
83
+ return HARNESSES['db-write'];
84
+ }
85
+ if (DEPLOY_PATTERNS.some((p) => p.test(commandText))) {
86
+ return HARNESSES['deploy'];
87
+ }
88
+ }
89
+
90
+ return null;
91
+ }
92
+
93
+ /**
94
+ * Return the harness name (e.g. "deploy") for a given tool call, or null.
95
+ */
96
+ function selectHarnessName(toolName, toolInput) {
97
+ const harnessPath = selectHarness(toolName, toolInput);
98
+ if (!harnessPath) return null;
99
+ return Object.entries(HARNESSES).find(([, p]) => p === harnessPath)?.[0] ?? null;
100
+ }
101
+
102
+ /**
103
+ * Return the full list of available harness names.
104
+ */
105
+ function listHarnesses() {
106
+ return Object.keys(HARNESSES);
107
+ }
108
+
109
+ /**
110
+ * Return the path for a harness by name.
111
+ */
112
+ function getHarnessPath(name) {
113
+ return HARNESSES[name] ?? null;
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Internal helpers
118
+ // ---------------------------------------------------------------------------
119
+
120
+ function extractCommandText(toolInput) {
121
+ if (!toolInput) return '';
122
+ if (typeof toolInput === 'string') return toolInput;
123
+ if (typeof toolInput === 'object') {
124
+ // Claude Code Bash tool: { command: "..." }
125
+ if (typeof toolInput.command === 'string') return toolInput.command;
126
+ // file_path for Edit/Write tools
127
+ if (typeof toolInput.file_path === 'string') return toolInput.file_path;
128
+ // Generic text fields
129
+ for (const key of ['input', 'text', 'content', 'query']) {
130
+ if (typeof toolInput[key] === 'string') return toolInput[key];
131
+ }
132
+ // Fall back to serialised form
133
+ try { return JSON.stringify(toolInput); } catch { return ''; }
134
+ }
135
+ return '';
136
+ }
137
+
138
+ module.exports = {
139
+ selectHarness,
140
+ selectHarnessName,
141
+ listHarnesses,
142
+ getHarnessPath,
143
+ extractCommandText,
144
+ HARNESSES,
145
+ DEPLOY_PATTERNS,
146
+ DB_WRITE_PATTERNS,
147
+ CODE_EDIT_TOOL_NAMES,
148
+ };
@@ -6,6 +6,7 @@ const { spawn } = require('child_process');
6
6
 
7
7
  const runner = require('./async-job-runner');
8
8
  const { buildHarnessJob } = require('./natural-language-harness');
9
+ const { ensureDir } = require('./fs-utils');
9
10
 
10
11
  const RUNNER_SCRIPT_PATH = path.join(__dirname, 'async-job-runner.js');
11
12
  const MANAGED_DPO_EXPORT_SCRIPT_PATH = path.join(__dirname, 'managed-dpo-export.js');
@@ -17,11 +18,6 @@ function nowIso() {
17
18
  return new Date().toISOString();
18
19
  }
19
20
 
20
- function ensureDir(dirPath) {
21
- if (!fs.existsSync(dirPath)) {
22
- fs.mkdirSync(dirPath, { recursive: true });
23
- }
24
- }
25
21
 
26
22
  function shellQuote(value) {
27
23
  return `'${String(value).replace(/'/g, `'\\''`)}'`;